deploy
This commit is contained in:
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>github-repo-inspector app</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
19
frontend/package.json
Normal file
19
frontend/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "github-repo-inspector-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --port 5173",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
283
frontend/src/App.jsx
Normal file
283
frontend/src/App.jsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { callSkill, loadAgentConfig, loadSession } from "./a2a.js";
|
||||
|
||||
const PHASES = ["auth", "repo list", "tree fetch", "file fetch", "classification", "analysis", "report"];
|
||||
const CATEGORIES = ["source", "config", "CI/CD", "Docker/container", "tests", "docs", "package/dependency", "infra/IaC", "scripts", "hidden/dotfiles", "assets/other"];
|
||||
|
||||
export function App() {
|
||||
const [config, setConfig] = useState(null);
|
||||
const [session, setSession] = useState(null);
|
||||
const [token, setToken] = useState("");
|
||||
const [repos, setRepos] = useState([]);
|
||||
const [selectedRepo, setSelectedRepo] = useState("");
|
||||
const [ref, setRef] = useState("");
|
||||
const [maxFiles, setMaxFiles] = useState(750);
|
||||
const [maxBytes, setMaxBytes] = useState(4000000);
|
||||
const [includeHidden, setIncludeHidden] = useState(true);
|
||||
const [loadingRepos, setLoadingRepos] = useState(false);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [phase, setPhase] = useState("auth");
|
||||
const [report, setReport] = useState(null);
|
||||
const [categoryFilter, setCategoryFilter] = useState("all");
|
||||
const [selectedCitation, setSelectedCitation] = useState(null);
|
||||
const [error, setError] = useState("");
|
||||
const [issueState, setIssueState] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
loadAgentConfig()
|
||||
.then((data) => {
|
||||
setConfig(data);
|
||||
return loadSession(data).catch(() => null);
|
||||
})
|
||||
.then(setSession)
|
||||
.catch((err) => setError(err.message || String(err)));
|
||||
}, []);
|
||||
|
||||
const repoParts = useMemo(() => {
|
||||
if (!selectedRepo) return null;
|
||||
const [owner, name] = selectedRepo.split("/");
|
||||
return { owner, repo: name };
|
||||
}, [selectedRepo]);
|
||||
|
||||
const json = report?.report?.json || report;
|
||||
const tree = json?.repo_map?.tree || [];
|
||||
const filteredTree = categoryFilter === "all" ? tree : tree.filter((item) => item.category === categoryFilter);
|
||||
const findings = json?.findings || [];
|
||||
const recommendations = json?.recommendations || [];
|
||||
const groupedFindings = groupBy(findings, (item) => item.category || "other");
|
||||
|
||||
async function listRepos(event) {
|
||||
event.preventDefault();
|
||||
if (!config) return;
|
||||
setError("");
|
||||
setLoadingRepos(true);
|
||||
setPhase("repo list");
|
||||
setReport(null);
|
||||
try {
|
||||
const result = await callSkill(config, "list_repositories", { github_token: token, per_page: 100, max_pages: 10 });
|
||||
if (!result.ok) throw new Error(result.error?.message || "Unable to list repositories");
|
||||
setRepos(result.repositories || []);
|
||||
if (result.repositories?.[0]) setSelectedRepo(result.repositories[0].full_name);
|
||||
} catch (err) {
|
||||
setError(err.message || String(err));
|
||||
} finally {
|
||||
setLoadingRepos(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectRepo(event) {
|
||||
event.preventDefault();
|
||||
if (!config || !repoParts) return;
|
||||
setError("");
|
||||
setReport(null);
|
||||
setScanning(true);
|
||||
const localPhases = ["auth", "tree fetch", "classification", "file fetch", "analysis", "report"];
|
||||
let phaseIndex = 0;
|
||||
setPhase(localPhases[phaseIndex]);
|
||||
const interval = window.setInterval(() => {
|
||||
phaseIndex = Math.min(phaseIndex + 1, localPhases.length - 1);
|
||||
setPhase(localPhases[phaseIndex]);
|
||||
}, 1200);
|
||||
try {
|
||||
const result = await callSkill(config, "inspect_repository", {
|
||||
owner: repoParts.owner,
|
||||
repo: repoParts.repo,
|
||||
github_token: token,
|
||||
ref,
|
||||
include_hidden: includeHidden,
|
||||
max_files: Number(maxFiles),
|
||||
max_bytes: Number(maxBytes),
|
||||
});
|
||||
if (!result.ok) throw new Error(result.error?.message || "Inspection failed");
|
||||
setReport(result);
|
||||
setPhase("report");
|
||||
} catch (err) {
|
||||
setError(err.message || String(err));
|
||||
} finally {
|
||||
window.clearInterval(interval);
|
||||
setScanning(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function createIssue(rec) {
|
||||
if (!config || !repoParts) return;
|
||||
const ok = window.confirm(`Create a GitHub issue for: ${rec.title}?`);
|
||||
if (!ok) return;
|
||||
setIssueState((prev) => ({ ...prev, [rec.id]: { loading: true } }));
|
||||
try {
|
||||
const result = await callSkill(config, "create_issue", {
|
||||
owner: repoParts.owner,
|
||||
repo: repoParts.repo,
|
||||
github_token: token,
|
||||
title: rec.issue_payload?.title || rec.title,
|
||||
body: rec.issue_payload?.body || rec.description,
|
||||
labels: rec.issue_payload?.labels || [],
|
||||
});
|
||||
if (!result.ok) throw new Error(result.error?.message || "Issue creation failed");
|
||||
setIssueState((prev) => ({ ...prev, [rec.id]: { loading: false, url: result.issue?.html_url } }));
|
||||
} catch (err) {
|
||||
setIssueState((prev) => ({ ...prev, [rec.id]: { loading: false, error: err.message || String(err) } }));
|
||||
}
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return <main className="loading"><p>{error || "Loading GitHub Repo Inspector…"}</p></main>;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="app">
|
||||
<header className="hero">
|
||||
<div>
|
||||
<p className="eyebrow">Public A2A agent</p>
|
||||
<h1>GitHub Repo Inspector</h1>
|
||||
<p className="subtitle">Inspect full GitHub repository trees, hidden files, CI/CD, Docker, dependencies, docs, tests, IaC, and source code with exact citations.</p>
|
||||
</div>
|
||||
<div className="status-card">
|
||||
<span>{session?.authenticated ? "Signed in" : "Public app"}</span>
|
||||
<strong>{config.agent?.name} v{config.agent?.version}</strong>
|
||||
<small>Required scopes: repo/read · issues:write for issue creation</small>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="setup-grid">
|
||||
<form className="card form" onSubmit={listRepos}>
|
||||
<h2>1. Connect GitHub</h2>
|
||||
<label>GitHub token (not stored)
|
||||
<input type="password" value={token} onChange={(e) => setToken(e.target.value)} placeholder="ghp_… or fine-grained token" />
|
||||
</label>
|
||||
<button disabled={loadingRepos} type="submit">{loadingRepos ? "Loading repos…" : "List repositories"}</button>
|
||||
<p className="hint">You can also call skills with a platform-configured consumer secret named <code>github_token</code>.</p>
|
||||
</form>
|
||||
|
||||
<form className="card form" onSubmit={inspectRepo}>
|
||||
<h2>2. Select repository</h2>
|
||||
<label>Repository
|
||||
<select value={selectedRepo} onChange={(e) => setSelectedRepo(e.target.value)}>
|
||||
<option value="">Select a repository…</option>
|
||||
{repos.map((repo) => <option key={repo.full_name} value={repo.full_name}>{repo.full_name}{repo.private ? " (private)" : ""}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>Branch/ref (optional)
|
||||
<input value={ref} onChange={(e) => setRef(e.target.value)} placeholder="default branch" />
|
||||
</label>
|
||||
<div className="toggles">
|
||||
<label><input type="checkbox" checked={includeHidden} onChange={(e) => setIncludeHidden(e.target.checked)} /> Include hidden/dotfiles</label>
|
||||
</div>
|
||||
<div className="limits">
|
||||
<label>Max files <input type="number" value={maxFiles} min="1" max="5000" onChange={(e) => setMaxFiles(e.target.value)} /></label>
|
||||
<label>Max bytes <input type="number" value={maxBytes} min="10000" max="25000000" onChange={(e) => setMaxBytes(e.target.value)} /></label>
|
||||
</div>
|
||||
<button disabled={scanning || !selectedRepo} type="submit">{scanning ? "Scanning…" : "Inspect repository"}</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
<section className="progress card">
|
||||
<h2>Scan progress</h2>
|
||||
<div className="phase-row">
|
||||
{PHASES.map((item) => <span key={item} className={item === phase ? "phase active" : "phase"}>{item}</span>)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{json && (
|
||||
<>
|
||||
<section className="score-grid">
|
||||
<div className="card score-main">
|
||||
<span>Total score</span>
|
||||
<strong>{json.scorecard?.total ?? "—"}</strong>
|
||||
<small>/100 production readiness</small>
|
||||
</div>
|
||||
{Object.entries(json.scorecard?.sub_scores || {}).map(([name, value]) => (
|
||||
<div className="card score" key={name}><span>{name}</span><strong>{value}</strong></div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="dashboard">
|
||||
<div className="card wide">
|
||||
<h2>Architecture summary</h2>
|
||||
<dl className="arch">
|
||||
<dt>Languages</dt><dd>{formatMap(json.architecture_summary?.languages)}</dd>
|
||||
<dt>Frameworks</dt><dd>{(json.architecture_summary?.frameworks || []).join(", ") || "Not inferred"}</dd>
|
||||
<dt>Entrypoints</dt><dd>{(json.architecture_summary?.entrypoints || []).slice(0, 12).join(", ") || "Not detected"}</dd>
|
||||
<dt>Data flow</dt><dd>{json.architecture_summary?.data_flow}</dd>
|
||||
<dt>External services</dt><dd>{(json.architecture_summary?.external_services || []).join(", ") || "None detected"}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="card wide">
|
||||
<h2>Repository map</h2>
|
||||
<div className="filters">
|
||||
<button className={categoryFilter === "all" ? "active" : ""} onClick={() => setCategoryFilter("all")}>all</button>
|
||||
{CATEGORIES.map((cat) => <button key={cat} className={categoryFilter === cat ? "active" : ""} onClick={() => setCategoryFilter(cat)}>{cat} ({json.repo_map?.counts_by_category?.[cat] || 0})</button>)}
|
||||
</div>
|
||||
<div className="tree">
|
||||
{filteredTree.slice(0, 600).map((item) => <div key={item.path} className="tree-row" style={{ paddingLeft: 12 + item.depth * 14 }}><span>{item.hidden ? "·" : ""}{item.path}</span><small>{item.category}</small></div>)}
|
||||
</div>
|
||||
<p className="hint">Skipped/limited files: {json.skipped_files?.length || 0}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h2>Risk cards</h2>
|
||||
<div className="risk-grid">
|
||||
{Object.entries(groupedFindings).map(([group, items]) => (
|
||||
<div className="risk-group" key={group}>
|
||||
<h3>{group}</h3>
|
||||
{items.slice(0, 12).map((finding) => <FindingCard key={finding.id} finding={finding} onCitation={setSelectedCitation} />)}
|
||||
</div>
|
||||
))}
|
||||
{!findings.length && <p>No evidence-backed risks detected by the configured heuristics.</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h2>Recommended fixes</h2>
|
||||
<div className="recs">
|
||||
{recommendations.map((rec) => (
|
||||
<article className="rec" key={rec.id}>
|
||||
<div>
|
||||
<strong>[{rec.severity}] {rec.title}</strong>
|
||||
<p>{rec.description}</p>
|
||||
<small>Impact: {rec.impact} · Effort: {rec.effort}</small>
|
||||
</div>
|
||||
<div className="rec-actions">
|
||||
<button onClick={() => setSelectedCitation(rec.citations?.[0])} disabled={!rec.citations?.[0]}>View citation</button>
|
||||
<button onClick={() => createIssue(rec)} disabled={issueState[rec.id]?.loading}>{issueState[rec.id]?.loading ? "Creating…" : "Create GitHub Issue"}</button>
|
||||
{issueState[rec.id]?.url && <a href={issueState[rec.id].url} target="_blank" rel="noreferrer">Created issue</a>}
|
||||
{issueState[rec.id]?.error && <small className="issue-error">{issueState[rec.id].error}</small>}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedCitation && <CitationModal citation={selectedCitation} onClose={() => setSelectedCitation(null)} />}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function FindingCard({ finding, onCitation }) {
|
||||
const first = finding.citations?.[0];
|
||||
return <article className={`finding ${finding.severity}`}><strong>{finding.title}</strong><p>{finding.description}</p><button onClick={() => onCitation(first)} disabled={!first}>View citation</button></article>;
|
||||
}
|
||||
|
||||
function CitationModal({ citation, onClose }) {
|
||||
return <div className="modal-backdrop" onClick={onClose}><div className="modal" onClick={(e) => e.stopPropagation()}><button className="close" onClick={onClose}>×</button><h2>{citation.path}</h2><p>Lines {citation.line_start || "structure"}{citation.line_end ? `-${citation.line_end}` : ""}</p><pre>{citation.snippet}</pre></div></div>;
|
||||
}
|
||||
|
||||
function groupBy(items, fn) {
|
||||
return items.reduce((acc, item) => {
|
||||
const key = fn(item);
|
||||
acc[key] ||= [];
|
||||
acc[key].push(item);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function formatMap(map = {}) {
|
||||
const parts = Object.entries(map).map(([k, v]) => `${k} (${v})`);
|
||||
return parts.join(", ") || "Not inferred";
|
||||
}
|
||||
55
frontend/src/a2a.js
Normal file
55
frontend/src/a2a.js
Normal file
@@ -0,0 +1,55 @@
|
||||
export const CONFIG_URL = import.meta.env.DEV ? "/app/config.json" : "./config.json";
|
||||
|
||||
export async function requestJson(url, init = {}) {
|
||||
const response = await fetch(url, { credentials: "same-origin", ...init });
|
||||
const text = await response.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
data = { raw: text };
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = data && (data.detail || data.message || data.raw);
|
||||
throw new Error(detail || `request failed: ${response.status}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function loadAgentConfig() {
|
||||
const config = await requestJson(CONFIG_URL);
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function loadSession(config) {
|
||||
if (!config?.endpoints?.session) return null;
|
||||
return requestJson(config.endpoints.session);
|
||||
}
|
||||
|
||||
export async function requireSession(config) {
|
||||
const session = await loadSession(config);
|
||||
if (!session?.authenticated) {
|
||||
const loginUrl = config.auth?.loginUrl;
|
||||
if (loginUrl) {
|
||||
const next = encodeURIComponent(window.location.href);
|
||||
window.location.assign(`${loginUrl}${loginUrl.includes("?") ? "&" : "?"}next=${next}`);
|
||||
}
|
||||
throw new Error("sign in required");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function callSkill(config, skillName, args) {
|
||||
if (config.auth?.invokeRequiresSession) {
|
||||
await requireSession(config);
|
||||
}
|
||||
return requestJson(`${config.endpoints.invoke}/${encodeURIComponent(skillName)}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ arguments: args }),
|
||||
});
|
||||
}
|
||||
|
||||
export function skillNames(config) {
|
||||
return (config?.skills || []).map((skill) => skill.name);
|
||||
}
|
||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App.jsx";
|
||||
import "./style.css";
|
||||
|
||||
createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
72
frontend/src/style.css
Normal file
72
frontend/src/style.css
Normal file
@@ -0,0 +1,72 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #070b12;
|
||||
color: #eef4ff;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-height: 100vh; background: radial-gradient(circle at top left, #19345a 0, transparent 35%), #070b12; }
|
||||
button, input, select, textarea { font: inherit; }
|
||||
button { cursor: pointer; border: 0; border-radius: 10px; padding: 10px 14px; background: #57d6a6; color: #06120d; font-weight: 700; }
|
||||
button:disabled { opacity: .55; cursor: not-allowed; }
|
||||
code { color: #9ee9ff; }
|
||||
.loading { min-height: 100vh; display: grid; place-items: center; color: #aebbd0; }
|
||||
.app { width: min(1440px, calc(100% - 32px)); margin: 0 auto; padding: 28px 0 60px; }
|
||||
.hero { display: grid; grid-template-columns: 1fr minmax(260px, 360px); gap: 22px; align-items: stretch; margin-bottom: 22px; }
|
||||
.eyebrow { margin: 0 0 10px; color: #57d6a6; text-transform: uppercase; letter-spacing: .12em; font-size: 12px; font-weight: 800; }
|
||||
h1, h2, h3, p { margin-top: 0; }
|
||||
h1 { margin-bottom: 14px; font-size: clamp(42px, 7vw, 92px); line-height: .88; letter-spacing: -0.07em; }
|
||||
h2 { margin-bottom: 16px; font-size: 22px; letter-spacing: -0.02em; }
|
||||
h3 { margin: 0 0 10px; color: #cdd9ea; }
|
||||
.subtitle { max-width: 850px; color: #b9c7da; font-size: 19px; line-height: 1.55; }
|
||||
.card, .status-card { border: 1px solid rgba(170, 205, 255, .14); border-radius: 20px; background: rgba(9, 17, 30, .82); box-shadow: 0 20px 50px rgba(0,0,0,.22); padding: 20px; backdrop-filter: blur(14px); }
|
||||
.status-card { display: grid; align-content: center; gap: 8px; }
|
||||
.status-card span, .hint, small { color: #8fa3bd; }
|
||||
.status-card strong { font-size: 20px; }
|
||||
.setup-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; margin-bottom: 18px; }
|
||||
.form { display: grid; gap: 14px; }
|
||||
label { display: grid; gap: 7px; color: #d8e5f8; }
|
||||
input, select, textarea { width: 100%; border: 1px solid rgba(170, 205, 255, .16); border-radius: 10px; color: #eef4ff; background: #07101d; padding: 11px 12px; outline: none; }
|
||||
input:focus, select:focus { border-color: #57d6a6; }
|
||||
.limits { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.toggles label { display: flex; align-items: center; gap: 8px; }
|
||||
.toggles input { width: auto; }
|
||||
.error { border: 1px solid #ff6b6b; color: #ffd0d0; background: rgba(130, 25, 25, .3); border-radius: 14px; padding: 14px 16px; margin-bottom: 18px; }
|
||||
.progress { margin-bottom: 18px; }
|
||||
.phase-row { display: flex; flex-wrap: wrap; gap: 9px; }
|
||||
.phase { color: #92a4bb; border: 1px solid rgba(170, 205, 255, .15); border-radius: 999px; padding: 8px 11px; background: #08111f; }
|
||||
.phase.active { color: #05150f; background: #57d6a6; border-color: #57d6a6; }
|
||||
.score-grid { display: grid; grid-template-columns: 1.4fr repeat(4, minmax(120px, 1fr)); gap: 14px; margin-bottom: 18px; }
|
||||
.score-main, .score { display: grid; gap: 4px; }
|
||||
.score-main strong { font-size: 64px; line-height: .95; color: #57d6a6; }
|
||||
.score strong { font-size: 34px; color: #9ee9ff; }
|
||||
.score span, .score-main span { color: #b9c7da; text-transform: capitalize; }
|
||||
.dashboard { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr); gap: 18px; margin-bottom: 18px; }
|
||||
.wide { min-width: 0; }
|
||||
.arch { display: grid; grid-template-columns: 160px 1fr; gap: 12px; }
|
||||
dt { color: #8fa3bd; } dd { margin: 0; color: #eef4ff; overflow-wrap: anywhere; }
|
||||
.filters { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
|
||||
.filters button { background: #122136; color: #cfe0f6; font-weight: 600; padding: 8px 10px; }
|
||||
.filters button.active { background: #57d6a6; color: #06120d; }
|
||||
.tree { max-height: 430px; overflow: auto; border: 1px solid rgba(170, 205, 255, .10); border-radius: 14px; background: #050b14; }
|
||||
.tree-row { display: flex; justify-content: space-between; gap: 12px; padding: 7px 12px; border-bottom: 1px solid rgba(170, 205, 255, .07); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; }
|
||||
.tree-row span { overflow-wrap: anywhere; }
|
||||
.tree-row small { white-space: nowrap; }
|
||||
.risk-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
|
||||
.risk-group { border: 1px solid rgba(170, 205, 255, .10); border-radius: 16px; padding: 14px; background: rgba(4, 10, 18, .62); }
|
||||
.finding { display: grid; gap: 8px; border-left: 4px solid #7893b5; background: #07101d; border-radius: 12px; padding: 12px; margin-bottom: 10px; }
|
||||
.finding.high { border-left-color: #ff6b6b; } .finding.medium { border-left-color: #ffd166; } .finding.low { border-left-color: #9ee9ff; }
|
||||
.finding p, .rec p { color: #b9c7da; line-height: 1.45; margin-bottom: 0; }
|
||||
.finding button, .rec-actions button { justify-self: start; background: #1c314f; color: #dcecff; }
|
||||
.recs { display: grid; gap: 12px; }
|
||||
.rec { display: grid; grid-template-columns: 1fr auto; gap: 18px; border: 1px solid rgba(170, 205, 255, .10); border-radius: 16px; padding: 16px; background: #07101d; }
|
||||
.rec-actions { min-width: 190px; display: grid; align-content: start; gap: 8px; }
|
||||
.rec-actions a { color: #57d6a6; font-weight: 800; }
|
||||
.issue-error { color: #ffb4b4; }
|
||||
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.72); display: grid; place-items: center; padding: 20px; z-index: 20; }
|
||||
.modal { width: min(900px, 100%); max-height: 85vh; overflow: auto; position: relative; }
|
||||
.modal.card, .modal { border: 1px solid rgba(170, 205, 255, .18); border-radius: 20px; background: #08111f; padding: 22px; }
|
||||
.close { position: absolute; top: 12px; right: 12px; width: 36px; height: 36px; padding: 0; border-radius: 50%; background: #1c314f; color: #fff; }
|
||||
pre { overflow: auto; background: #02060c; color: #dbe9ff; border-radius: 12px; padding: 16px; line-height: 1.5; font-size: 13px; }
|
||||
@media (max-width: 1000px) { .hero, .setup-grid, .dashboard, .rec { grid-template-columns: 1fr; } .score-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .risk-grid { grid-template-columns: 1fr; } .arch { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 600px) { .app { width: min(100% - 20px, 1440px); padding-top: 14px; } .limits, .score-grid { grid-template-columns: 1fr; } h1 { font-size: 48px; } }
|
||||
18
frontend/vite.config.js
Normal file
18
frontend/vite.config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
const agent = process.env.A2A_DEV_AGENT_URL || "http://127.0.0.1:8000";
|
||||
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/app": agent,
|
||||
"/invoke": agent,
|
||||
"/auth": agent,
|
||||
"/mcp": agent,
|
||||
"/.well-known": agent,
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user