feat: complete phase 5 -- error hardening, frontend, Docker, demo, docs
Backend: - ConversationTracker: Protocol + PostgresConversationTracker for lifecycle tracking - Error handler: ErrorCategory enum, classify_error(), with_retry() exponential backoff - Wire PostgresAnalyticsRecorder + ConversationTracker into ws_handler - Rate limiting (10 msg/10s per thread), edge case hardening - Health endpoint GET /api/health, version 0.5.0 - Demo seed data script + sample OpenAPI spec Frontend (all new): - React Router with NavBar (Chat / Replay / Dashboard / Review) - ReplayListPage + ReplayPage with ReplayTimeline component - DashboardPage with MetricCard, range selector, zero-state - ReviewPage for OpenAPI classification review - ErrorBanner for WebSocket disconnect handling - API client (api.ts) with typed fetch wrappers Infrastructure: - Frontend Dockerfile (multi-stage node -> nginx) - nginx.conf with SPA routing + API/WS proxy - docker-compose.yml with frontend service + healthchecks - .env.example files (root + backend) Documentation: - README.md with quick start and architecture - Agent configuration guide - OpenAPI import guide - Deployment guide - Demo script 48 new tests, 449 total passing, 92.87% coverage
This commit is contained in:
@@ -2,6 +2,7 @@ import { useCallback, useState } from "react";
|
||||
import { AgentAction } from "../components/AgentAction";
|
||||
import { ChatInput } from "../components/ChatInput";
|
||||
import { ChatMessages } from "../components/ChatMessages";
|
||||
import { ErrorBanner } from "../components/ErrorBanner";
|
||||
import { InterruptPrompt } from "../components/InterruptPrompt";
|
||||
import { useWebSocket } from "../hooks/useWebSocket";
|
||||
import type {
|
||||
@@ -95,7 +96,7 @@ export function ChatPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { status, sendMessage, sendInterruptResponse } =
|
||||
const { status, sendMessage, sendInterruptResponse, reconnect } =
|
||||
useWebSocket(handleServerMessage);
|
||||
|
||||
const handleSend = useCallback(
|
||||
@@ -130,6 +131,7 @@ export function ChatPage() {
|
||||
<h1 style={styles.title}>Smart Support</h1>
|
||||
<StatusIndicator status={status} />
|
||||
</div>
|
||||
<ErrorBanner status={status} onReconnect={reconnect} />
|
||||
<ChatMessages messages={messages} />
|
||||
{toolActions.length > 0 && (
|
||||
<div style={styles.actionsBar}>
|
||||
|
||||
184
frontend/src/pages/DashboardPage.tsx
Normal file
184
frontend/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchAnalytics } from "../api";
|
||||
import type { AnalyticsData } from "../api";
|
||||
import { MetricCard } from "../components/MetricCard";
|
||||
|
||||
const RANGE_OPTIONS = [
|
||||
{ value: "7d", label: "7 days" },
|
||||
{ value: "14d", label: "14 days" },
|
||||
{ value: "30d", label: "30 days" },
|
||||
];
|
||||
|
||||
function pct(value: number): string {
|
||||
return `${(value * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function formatCost(usd: number): string {
|
||||
return usd < 0.01 ? "<$0.01" : `$${usd.toFixed(3)}`;
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const [range, setRange] = useState("7d");
|
||||
const [data, setData] = useState<AnalyticsData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetchAnalytics(range)
|
||||
.then(setData)
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [range]);
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<h2 style={styles.heading}>Dashboard</h2>
|
||||
<div style={styles.rangeSelector}>
|
||||
{RANGE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setRange(opt.value)}
|
||||
style={{
|
||||
...styles.rangeBtn,
|
||||
...(range === opt.value ? styles.rangeBtnActive : {}),
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && <div style={styles.center}>Loading analytics...</div>}
|
||||
{error && <div style={styles.error}>Error: {error}</div>}
|
||||
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
{data.total_conversations === 0 ? (
|
||||
<div style={styles.empty}>
|
||||
No conversations yet. Start a chat to see analytics here.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={styles.metricsGrid}>
|
||||
<MetricCard
|
||||
label="Total Conversations"
|
||||
value={data.total_conversations}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Resolution Rate"
|
||||
value={pct(data.resolution_rate)}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Escalation Rate"
|
||||
value={pct(data.escalation_rate)}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Avg Turns"
|
||||
value={data.avg_turns_per_conversation.toFixed(1)}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Total Tokens"
|
||||
value={data.total_tokens.toLocaleString()}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Total Cost"
|
||||
value={formatCost(data.total_cost_usd)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 style={styles.sectionHeading}>Agent Usage</h3>
|
||||
{data.agent_usage.length === 0 ? (
|
||||
<div style={styles.empty}>No agent data.</div>
|
||||
) : (
|
||||
<table style={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={styles.th}>Agent</th>
|
||||
<th style={styles.th}>Messages</th>
|
||||
<th style={styles.th}>Tokens</th>
|
||||
<th style={styles.th}>Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.agent_usage.map((a) => (
|
||||
<tr key={a.agent_name}>
|
||||
<td style={styles.td}>{a.agent_name}</td>
|
||||
<td style={styles.td}>{a.message_count}</td>
|
||||
<td style={styles.td}>{a.total_tokens.toLocaleString()}</td>
|
||||
<td style={styles.td}>{formatCost(a.total_cost_usd)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<h3 style={styles.sectionHeading}>Interrupt Stats</h3>
|
||||
<div style={styles.metricsGrid}>
|
||||
<MetricCard label="Total Interrupts" value={data.interrupt_stats.total} />
|
||||
<MetricCard label="Approved" value={data.interrupt_stats.approved} />
|
||||
<MetricCard label="Rejected" value={data.interrupt_stats.rejected} />
|
||||
<MetricCard label="Expired" value={data.interrupt_stats.expired} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
container: { padding: "24px", maxWidth: "1000px", margin: "0 auto" },
|
||||
header: {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
heading: { fontSize: "20px", fontWeight: 700, margin: 0 },
|
||||
rangeSelector: { display: "flex", gap: "4px" },
|
||||
rangeBtn: {
|
||||
padding: "5px 14px",
|
||||
border: "1px solid #e0e0e0",
|
||||
borderRadius: "4px",
|
||||
background: "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: "13px",
|
||||
color: "#555",
|
||||
},
|
||||
rangeBtnActive: {
|
||||
background: "#1976d2",
|
||||
color: "#fff",
|
||||
borderColor: "#1976d2",
|
||||
},
|
||||
metricsGrid: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap" as const,
|
||||
gap: "12px",
|
||||
marginBottom: "24px",
|
||||
},
|
||||
sectionHeading: {
|
||||
fontSize: "15px",
|
||||
fontWeight: 600,
|
||||
marginBottom: "12px",
|
||||
color: "#333",
|
||||
},
|
||||
table: { width: "100%", borderCollapse: "collapse", fontSize: "13px", marginBottom: "24px" },
|
||||
th: {
|
||||
textAlign: "left",
|
||||
padding: "8px 12px",
|
||||
borderBottom: "2px solid #e0e0e0",
|
||||
color: "#555",
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
fontSize: "11px",
|
||||
},
|
||||
td: { padding: "10px 12px", borderBottom: "1px solid #f0f0f0" },
|
||||
center: { padding: "48px", textAlign: "center", color: "#888" },
|
||||
error: { padding: "24px", color: "#c62828" },
|
||||
empty: { color: "#888", fontSize: "14px", padding: "16px 0" },
|
||||
};
|
||||
133
frontend/src/pages/ReplayListPage.tsx
Normal file
133
frontend/src/pages/ReplayListPage.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { fetchConversations } from "../api";
|
||||
import type { ConversationSummary } from "../api";
|
||||
|
||||
export function ReplayListPage() {
|
||||
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const perPage = 20;
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetchConversations(page, perPage)
|
||||
.then((data) => {
|
||||
setConversations(data.conversations);
|
||||
setTotal(data.total);
|
||||
})
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [page]);
|
||||
|
||||
if (loading) {
|
||||
return <div style={styles.center}>Loading conversations...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div style={styles.error}>Error: {error}</div>;
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / perPage);
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<h2 style={styles.heading}>Conversations</h2>
|
||||
{conversations.length === 0 ? (
|
||||
<div style={styles.empty}>No conversations yet.</div>
|
||||
) : (
|
||||
<>
|
||||
<table style={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={styles.th}>Thread ID</th>
|
||||
<th style={styles.th}>Started</th>
|
||||
<th style={styles.th}>Turns</th>
|
||||
<th style={styles.th}>Agents</th>
|
||||
<th style={styles.th}>Resolution</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{conversations.map((c) => (
|
||||
<tr
|
||||
key={c.thread_id}
|
||||
onClick={() => navigate(`/replay/${c.thread_id}`)}
|
||||
style={styles.row}
|
||||
>
|
||||
<td style={styles.td}>
|
||||
<span style={styles.threadId}>{c.thread_id}</span>
|
||||
</td>
|
||||
<td style={styles.td}>
|
||||
{new Date(c.started_at).toLocaleString()}
|
||||
</td>
|
||||
<td style={styles.td}>{c.turn_count}</td>
|
||||
<td style={styles.td}>{c.agents_used.join(", ") || "—"}</td>
|
||||
<td style={styles.td}>{c.resolution_type ?? "open"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style={styles.pagination}>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
style={styles.pageBtn}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span style={{ fontSize: "13px", color: "#555" }}>
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
style={styles.pageBtn}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
container: { padding: "24px", maxWidth: "1000px", margin: "0 auto" },
|
||||
heading: { fontSize: "20px", fontWeight: 700, marginBottom: "16px" },
|
||||
center: { padding: "48px", textAlign: "center", color: "#888" },
|
||||
error: { padding: "24px", color: "#c62828" },
|
||||
empty: { color: "#888", fontSize: "14px" },
|
||||
table: { width: "100%", borderCollapse: "collapse", fontSize: "13px" },
|
||||
th: {
|
||||
textAlign: "left",
|
||||
padding: "8px 12px",
|
||||
borderBottom: "2px solid #e0e0e0",
|
||||
color: "#555",
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
fontSize: "11px",
|
||||
letterSpacing: "0.5px",
|
||||
},
|
||||
td: { padding: "10px 12px", borderBottom: "1px solid #f0f0f0" },
|
||||
row: { cursor: "pointer", transition: "background 0.1s" },
|
||||
threadId: { fontFamily: "monospace", fontSize: "12px", color: "#1976d2" },
|
||||
pagination: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "12px",
|
||||
marginTop: "16px",
|
||||
},
|
||||
pageBtn: {
|
||||
padding: "6px 14px",
|
||||
border: "1px solid #e0e0e0",
|
||||
borderRadius: "4px",
|
||||
background: "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: "13px",
|
||||
},
|
||||
};
|
||||
89
frontend/src/pages/ReplayPage.tsx
Normal file
89
frontend/src/pages/ReplayPage.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { fetchReplay } from "../api";
|
||||
import type { ReplayStep } from "../api";
|
||||
import { ReplayTimeline } from "../components/ReplayTimeline";
|
||||
|
||||
export function ReplayPage() {
|
||||
const { threadId } = useParams<{ threadId: string }>();
|
||||
const [steps, setSteps] = useState<ReplayStep[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const perPage = 20;
|
||||
|
||||
useEffect(() => {
|
||||
if (!threadId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetchReplay(threadId, page, perPage)
|
||||
.then((data) => {
|
||||
setSteps(data.steps);
|
||||
setTotal(data.total);
|
||||
})
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [threadId, page]);
|
||||
|
||||
if (!threadId) {
|
||||
return <div style={styles.error}>No thread ID provided.</div>;
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / perPage);
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<h2 style={styles.heading}>
|
||||
Replay:{" "}
|
||||
<span style={styles.threadId}>{threadId}</span>
|
||||
</h2>
|
||||
{loading && <div style={styles.center}>Loading replay...</div>}
|
||||
{error && <div style={styles.error}>Error: {error}</div>}
|
||||
{!loading && !error && <ReplayTimeline steps={steps} />}
|
||||
{!loading && totalPages > 1 && (
|
||||
<div style={styles.pagination}>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
style={styles.pageBtn}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span style={{ fontSize: "13px", color: "#555" }}>
|
||||
Page {page} of {totalPages} ({total} steps)
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
style={styles.pageBtn}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
container: { padding: "24px", maxWidth: "800px", margin: "0 auto" },
|
||||
heading: { fontSize: "20px", fontWeight: 700, marginBottom: "20px" },
|
||||
threadId: { fontFamily: "monospace", fontSize: "16px", color: "#1976d2" },
|
||||
center: { padding: "48px", textAlign: "center", color: "#888" },
|
||||
error: { padding: "24px", color: "#c62828" },
|
||||
pagination: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "12px",
|
||||
marginTop: "20px",
|
||||
},
|
||||
pageBtn: {
|
||||
padding: "6px 14px",
|
||||
border: "1px solid #e0e0e0",
|
||||
borderRadius: "4px",
|
||||
background: "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: "13px",
|
||||
},
|
||||
};
|
||||
277
frontend/src/pages/ReviewPage.tsx
Normal file
277
frontend/src/pages/ReviewPage.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface ImportJob {
|
||||
job_id: string;
|
||||
status: "pending" | "processing" | "done" | "error";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface EndpointClassification {
|
||||
path: string;
|
||||
method: string;
|
||||
summary: string;
|
||||
access_type: string;
|
||||
agent_group: string;
|
||||
}
|
||||
|
||||
interface JobResult {
|
||||
job_id: string;
|
||||
status: string;
|
||||
endpoints: EndpointClassification[];
|
||||
}
|
||||
|
||||
export function ReviewPage() {
|
||||
const [url, setUrl] = useState("");
|
||||
const [job, setJob] = useState<ImportJob | null>(null);
|
||||
const [result, setResult] = useState<JobResult | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [classifications, setClassifications] = useState<EndpointClassification[]>([]);
|
||||
const pollRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollRef.current) clearTimeout(pollRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function pollJob(jobId: string) {
|
||||
fetch(`/api/openapi/jobs/${encodeURIComponent(jobId)}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
const j: ImportJob = data.data ?? data;
|
||||
setJob(j);
|
||||
if (j.status === "done") {
|
||||
return fetch(`/api/openapi/jobs/${encodeURIComponent(jobId)}/result`)
|
||||
.then((r) => r.json())
|
||||
.then((rdata) => {
|
||||
const res: JobResult = rdata.data ?? rdata;
|
||||
setResult(res);
|
||||
setClassifications(res.endpoints ?? []);
|
||||
});
|
||||
} else if (j.status === "error") {
|
||||
return;
|
||||
} else {
|
||||
pollRef.current = setTimeout(() => pollJob(jobId), 2000);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
pollRef.current = setTimeout(() => pollJob(jobId), 3000);
|
||||
});
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!url.trim()) return;
|
||||
setSubmitting(true);
|
||||
setSubmitError(null);
|
||||
setJob(null);
|
||||
setResult(null);
|
||||
setClassifications([]);
|
||||
|
||||
fetch("/api/openapi/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
const j: ImportJob = data.data ?? data;
|
||||
setJob(j);
|
||||
if (j.job_id) pollJob(j.job_id);
|
||||
})
|
||||
.catch((err: Error) => setSubmitError(err.message))
|
||||
.finally(() => setSubmitting(false));
|
||||
}
|
||||
|
||||
function handleFieldChange(
|
||||
idx: number,
|
||||
field: keyof EndpointClassification,
|
||||
value: string
|
||||
) {
|
||||
setClassifications((prev) =>
|
||||
prev.map((c, i) => (i === idx ? { ...c, [field]: value } : c))
|
||||
);
|
||||
}
|
||||
|
||||
function handleApprove() {
|
||||
if (!job?.job_id) return;
|
||||
fetch(`/api/openapi/jobs/${encodeURIComponent(job.job_id)}/approve`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ endpoints: classifications }),
|
||||
}).then(() => {
|
||||
alert("Approved and saved.");
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<h2 style={styles.heading}>OpenAPI Import & Review</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} style={styles.form}>
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://example.com/openapi.yaml"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
style={styles.input}
|
||||
required
|
||||
/>
|
||||
<button type="submit" disabled={submitting} style={styles.submitBtn}>
|
||||
{submitting ? "Importing..." : "Import"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{submitError && <div style={styles.error}>Error: {submitError}</div>}
|
||||
|
||||
{job && (
|
||||
<div style={styles.statusBox}>
|
||||
<strong>Job:</strong> {job.job_id} — Status:{" "}
|
||||
<span
|
||||
style={{
|
||||
color:
|
||||
job.status === "done"
|
||||
? "#388e3c"
|
||||
: job.status === "error"
|
||||
? "#c62828"
|
||||
: "#f57c00",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{job.status}
|
||||
</span>
|
||||
{job.error && (
|
||||
<div style={{ color: "#c62828", marginTop: "4px" }}>{job.error}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && classifications.length > 0 && (
|
||||
<>
|
||||
<h3 style={styles.sectionHeading}>
|
||||
Endpoint Classifications ({classifications.length})
|
||||
</h3>
|
||||
<p style={styles.hint}>
|
||||
Review and edit the access_type and agent_group before approving.
|
||||
</p>
|
||||
<table style={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={styles.th}>Method</th>
|
||||
<th style={styles.th}>Path</th>
|
||||
<th style={styles.th}>Summary</th>
|
||||
<th style={styles.th}>Access Type</th>
|
||||
<th style={styles.th}>Agent Group</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{classifications.map((c, idx) => (
|
||||
<tr key={`${c.method}-${c.path}`}>
|
||||
<td style={styles.td}>
|
||||
<span style={{ fontWeight: 600, fontSize: "11px" }}>
|
||||
{c.method.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ ...styles.td, fontFamily: "monospace", fontSize: "12px" }}>
|
||||
{c.path}
|
||||
</td>
|
||||
<td style={styles.td}>{c.summary}</td>
|
||||
<td style={styles.td}>
|
||||
<select
|
||||
value={c.access_type}
|
||||
onChange={(e) => handleFieldChange(idx, "access_type", e.target.value)}
|
||||
style={styles.select}
|
||||
>
|
||||
<option value="read">read</option>
|
||||
<option value="write">write</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</td>
|
||||
<td style={styles.td}>
|
||||
<input
|
||||
type="text"
|
||||
value={c.agent_group}
|
||||
onChange={(e) => handleFieldChange(idx, "agent_group", e.target.value)}
|
||||
style={styles.textInput}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<button onClick={handleApprove} style={styles.approveBtn}>
|
||||
Approve & Save
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
container: { padding: "24px", maxWidth: "1000px", margin: "0 auto" },
|
||||
heading: { fontSize: "20px", fontWeight: 700, marginBottom: "16px" },
|
||||
form: { display: "flex", gap: "8px", marginBottom: "16px" },
|
||||
input: {
|
||||
flex: 1,
|
||||
padding: "8px 12px",
|
||||
border: "1px solid #e0e0e0",
|
||||
borderRadius: "4px",
|
||||
fontSize: "14px",
|
||||
},
|
||||
submitBtn: {
|
||||
padding: "8px 20px",
|
||||
background: "#1976d2",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
fontWeight: 600,
|
||||
},
|
||||
error: { color: "#c62828", marginBottom: "12px" },
|
||||
statusBox: {
|
||||
background: "#f9f9f9",
|
||||
border: "1px solid #e0e0e0",
|
||||
padding: "10px 14px",
|
||||
borderRadius: "4px",
|
||||
marginBottom: "16px",
|
||||
fontSize: "13px",
|
||||
},
|
||||
sectionHeading: { fontSize: "15px", fontWeight: 600, marginBottom: "8px" },
|
||||
hint: { fontSize: "12px", color: "#888", marginBottom: "12px" },
|
||||
table: { width: "100%", borderCollapse: "collapse", fontSize: "13px", marginBottom: "16px" },
|
||||
th: {
|
||||
textAlign: "left",
|
||||
padding: "8px 10px",
|
||||
borderBottom: "2px solid #e0e0e0",
|
||||
fontSize: "11px",
|
||||
textTransform: "uppercase",
|
||||
color: "#555",
|
||||
},
|
||||
td: { padding: "8px 10px", borderBottom: "1px solid #f0f0f0" },
|
||||
select: {
|
||||
padding: "3px 6px",
|
||||
border: "1px solid #e0e0e0",
|
||||
borderRadius: "3px",
|
||||
fontSize: "12px",
|
||||
},
|
||||
textInput: {
|
||||
padding: "3px 6px",
|
||||
border: "1px solid #e0e0e0",
|
||||
borderRadius: "3px",
|
||||
fontSize: "12px",
|
||||
width: "100%",
|
||||
},
|
||||
approveBtn: {
|
||||
padding: "8px 20px",
|
||||
background: "#388e3c",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
fontWeight: 600,
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user