feat: add dashboard frontend (Vite + React + Bootstrap)
Scaffold the mm dashboard at dashboard/ with: - Pipeline stats overview (repos, indexed, analyzed, ranked, pending) - Ranked repo list with composite score progress bars and pagination - Repo detail view with index stats, LLM analysis, and score breakdown - Action queue with status filtering and approve/veto controls Stack: Vite 6, React 19, SWC, TypeScript, React Bootstrap (dark theme), React Router 7. Builds to static files deployed to /var/www/mm/ on the nginx host. Dev server proxies /api to localhost:17380. Deploy script updated to build frontend and rsync to nginx host with correct ownership (root:root) and SELinux context (restorecon). Tested end-to-end at https://mm.internal/ — dashboard loads, stats and repo data populate from the API. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
3
dashboard/.gitignore
vendored
Normal file
3
dashboard/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
12
dashboard/index.html
Normal file
12
dashboard/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>mm dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1998
dashboard/package-lock.json
generated
Normal file
1998
dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
dashboard/package.json
Normal file
25
dashboard/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "mm-dashboard",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"bootstrap": "^5.3.3",
|
||||
"react": "^19.0.0",
|
||||
"react-bootstrap": "^2.10.7",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react-swc": "^4.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
21
dashboard/src/App.tsx
Normal file
21
dashboard/src/App.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import Layout from "./components/Layout";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import Repos from "./pages/Repos";
|
||||
import RepoDetail from "./pages/RepoDetail";
|
||||
import Actions from "./pages/Actions";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/repos" element={<Repos />} />
|
||||
<Route path="/repos/:id" element={<RepoDetail />} />
|
||||
<Route path="/actions" element={<Actions />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
39
dashboard/src/api.ts
Normal file
39
dashboard/src/api.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { PipelineStats, RepoSummary, RepoDetail, ActionItem } from "./types";
|
||||
|
||||
const BASE = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
||||
|
||||
async function get<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`);
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function post<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, { method: "POST" });
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function fetchStats(): Promise<PipelineStats> {
|
||||
return get("/stats");
|
||||
}
|
||||
|
||||
export function fetchRepos(limit = 50, offset = 0): Promise<RepoSummary[]> {
|
||||
return get(`/repos?limit=${limit}&offset=${offset}`);
|
||||
}
|
||||
|
||||
export function fetchRepo(id: string): Promise<RepoDetail> {
|
||||
return get(`/repos/${id}`);
|
||||
}
|
||||
|
||||
export function fetchActions(status = "pending", limit = 50): Promise<ActionItem[]> {
|
||||
return get(`/actions?status=${status}&limit=${limit}`);
|
||||
}
|
||||
|
||||
export function approveAction(id: string): Promise<{ status: string }> {
|
||||
return post(`/actions/${id}/approve`);
|
||||
}
|
||||
|
||||
export function vetoAction(id: string): Promise<{ status: string }> {
|
||||
return post(`/actions/${id}/veto`);
|
||||
}
|
||||
25
dashboard/src/components/Layout.tsx
Normal file
25
dashboard/src/components/Layout.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Outlet, NavLink } from "react-router-dom";
|
||||
import { Navbar, Nav, Container } from "react-bootstrap";
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<>
|
||||
<Navbar bg="dark" variant="dark" expand="sm" className="mb-3">
|
||||
<Container>
|
||||
<Navbar.Brand as={NavLink} to="/">mm</Navbar.Brand>
|
||||
<Navbar.Toggle />
|
||||
<Navbar.Collapse>
|
||||
<Nav>
|
||||
<Nav.Link as={NavLink} to="/">Dashboard</Nav.Link>
|
||||
<Nav.Link as={NavLink} to="/repos">Repos</Nav.Link>
|
||||
<Nav.Link as={NavLink} to="/actions">Actions</Nav.Link>
|
||||
</Nav>
|
||||
</Navbar.Collapse>
|
||||
</Container>
|
||||
</Navbar>
|
||||
<Container>
|
||||
<Outlet />
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
dashboard/src/main.tsx
Normal file
10
dashboard/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import "bootstrap/dist/css/bootstrap.min.css";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
152
dashboard/src/pages/Actions.tsx
Normal file
152
dashboard/src/pages/Actions.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Table, Badge, Button, ButtonGroup, Spinner, Alert, Collapse } from "react-bootstrap";
|
||||
import { fetchActions, approveAction, vetoAction } from "../api";
|
||||
import type { ActionItem } from "../types";
|
||||
|
||||
const STATUSES = ["pending", "in_progress", "completed", "failed", "vetoed"];
|
||||
|
||||
function kindBadge(kind: string): string {
|
||||
switch (kind) {
|
||||
case "create_issue": return "primary";
|
||||
case "create_discussion": return "info";
|
||||
case "review_pr": return "warning";
|
||||
case "flag_for_human": return "danger";
|
||||
default: return "secondary";
|
||||
}
|
||||
}
|
||||
|
||||
function kindLabel(kind: string): string {
|
||||
return kind.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const seconds = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (seconds < 60) return "just now";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
export default function Actions() {
|
||||
const [status, setStatus] = useState("pending");
|
||||
const [actions, setActions] = useState<ActionItem[]>([]);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
fetchActions(status)
|
||||
.then(setActions)
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(load, [status]);
|
||||
|
||||
const handleApprove = async (id: string) => {
|
||||
await approveAction(id);
|
||||
load();
|
||||
};
|
||||
|
||||
const handleVeto = async (id: string) => {
|
||||
await vetoAction(id);
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h5 className="mb-3">Actions</h5>
|
||||
<ButtonGroup className="mb-3">
|
||||
{STATUSES.map((s) => (
|
||||
<Button
|
||||
key={s}
|
||||
variant={s === status ? "light" : "outline-light"}
|
||||
size="sm"
|
||||
onClick={() => setStatus(s)}
|
||||
>
|
||||
{s.replace(/_/g, " ")}
|
||||
</Button>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
|
||||
{loading && <div className="text-center mt-3"><Spinner /></div>}
|
||||
{error && <Alert variant="danger">{error}</Alert>}
|
||||
{!loading && actions.length === 0 && (
|
||||
<p className="text-muted">No {status.replace(/_/g, " ")} actions</p>
|
||||
)}
|
||||
{!loading && actions.length > 0 && (
|
||||
<Table variant="dark" hover size="sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Repo</th>
|
||||
<th>Kind</th>
|
||||
<th>Title</th>
|
||||
<th>Created</th>
|
||||
{status === "pending" && <th>Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{actions.map((a) => (
|
||||
<>
|
||||
<tr
|
||||
key={a.id}
|
||||
onClick={() => setExpanded(expanded === a.id ? null : a.id)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<td>
|
||||
<Link to={`/repos/${a.repo_id}`} onClick={(e) => e.stopPropagation()}>
|
||||
{a.repo_name}
|
||||
</Link>
|
||||
</td>
|
||||
<td><Badge bg={kindBadge(a.kind)}>{kindLabel(a.kind)}</Badge></td>
|
||||
<td>{a.title}</td>
|
||||
<td className="text-muted">{timeAgo(a.created_at)}</td>
|
||||
{status === "pending" && (
|
||||
<td onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="success"
|
||||
size="sm"
|
||||
className="me-1"
|
||||
onClick={() => handleApprove(a.id)}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="sm"
|
||||
onClick={() => handleVeto(a.id)}
|
||||
>
|
||||
Veto
|
||||
</Button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
<tr key={`${a.id}-body`}>
|
||||
<td colSpan={status === "pending" ? 5 : 4} className="p-0 border-0">
|
||||
<Collapse in={expanded === a.id}>
|
||||
<div className="p-3 bg-dark">
|
||||
<pre className="mb-0 text-light" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{a.body}
|
||||
</pre>
|
||||
{a.remote_url && (
|
||||
<a href={a.remote_url} target="_blank" rel="noreferrer" className="mt-2 d-block">
|
||||
View on forge
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</Collapse>
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
103
dashboard/src/pages/Dashboard.tsx
Normal file
103
dashboard/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Row, Col, Card, Table, ProgressBar, Spinner, Alert } from "react-bootstrap";
|
||||
import { fetchStats, fetchRepos } from "../api";
|
||||
import type { PipelineStats, RepoSummary } from "../types";
|
||||
|
||||
function scoreVariant(score: number): string {
|
||||
if (score >= 0.75) return "success";
|
||||
if (score >= 0.5) return "info";
|
||||
if (score >= 0.25) return "warning";
|
||||
return "danger";
|
||||
}
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const seconds = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (seconds < 60) return "just now";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [stats, setStats] = useState<PipelineStats | null>(null);
|
||||
const [repos, setRepos] = useState<RepoSummary[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([fetchStats(), fetchRepos(10, 0)])
|
||||
.then(([s, r]) => { setStats(s); setRepos(r); })
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) return <div className="text-center mt-5"><Spinner /></div>;
|
||||
if (error) return <Alert variant="danger">{error}</Alert>;
|
||||
if (!stats) return null;
|
||||
|
||||
const statCards: [string, number, string][] = [
|
||||
["Repos", stats.total_repos, "primary"],
|
||||
["Indexed", stats.indexed_repos, "info"],
|
||||
["Analyzed", stats.analyzed_repos, "info"],
|
||||
["Ranked", stats.ranked_repos, "success"],
|
||||
["Pending Actions", stats.pending_actions, stats.pending_actions > 0 ? "warning" : "secondary"],
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row className="mb-4">
|
||||
{statCards.map(([label, value, variant]) => (
|
||||
<Col key={label}>
|
||||
<Card bg="dark" border={variant} className="text-center">
|
||||
<Card.Body>
|
||||
<Card.Title className={`text-${variant} fs-2`}>{value}</Card.Title>
|
||||
<Card.Text className="text-muted">{label}</Card.Text>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<h5 className="mb-3">Top Ranked Repos</h5>
|
||||
<Table variant="dark" hover size="sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Repository</th>
|
||||
<th>Language</th>
|
||||
<th>Score</th>
|
||||
<th>Last Push</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{repos.map((r, i) => (
|
||||
<tr key={r.id}>
|
||||
<td>{i + 1}</td>
|
||||
<td><Link to={`/repos/${r.id}`}>{r.full_name}</Link></td>
|
||||
<td><span className="text-muted">{r.language ?? ""}</span></td>
|
||||
<td style={{ width: "20%" }}>
|
||||
{r.composite_score != null ? (
|
||||
<ProgressBar
|
||||
now={r.composite_score * 100}
|
||||
variant={scoreVariant(r.composite_score)}
|
||||
label={`${Math.round(r.composite_score * 100)}%`}
|
||||
style={{ minWidth: "60px" }}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-muted">
|
||||
{r.last_pushed_at ? timeAgo(r.last_pushed_at) : "-"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
171
dashboard/src/pages/RepoDetail.tsx
Normal file
171
dashboard/src/pages/RepoDetail.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { Card, Row, Col, Badge, ProgressBar, Spinner, Alert, ListGroup } from "react-bootstrap";
|
||||
import { fetchRepo } from "../api";
|
||||
import type { RepoDetail as RepoDetailType } from "../types";
|
||||
|
||||
function scoreVariant(score: number): string {
|
||||
if (score >= 0.75) return "success";
|
||||
if (score >= 0.5) return "info";
|
||||
if (score >= 0.25) return "warning";
|
||||
return "danger";
|
||||
}
|
||||
|
||||
function ScoreBar({ label, value }: { label: string; value: number | null | undefined }) {
|
||||
if (value == null) return null;
|
||||
const pct = Math.round(value * 100);
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<div className="d-flex justify-content-between mb-1">
|
||||
<small>{label}</small>
|
||||
<small>{pct}%</small>
|
||||
</div>
|
||||
<ProgressBar now={pct} variant={scoreVariant(value)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RepoDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [data, setData] = useState<RepoDetailType | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
fetchRepo(id)
|
||||
.then(setData)
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <div className="text-center mt-5"><Spinner /></div>;
|
||||
if (error) return <Alert variant="danger">{error}</Alert>;
|
||||
if (!data) return <Alert variant="warning">Repo not found</Alert>;
|
||||
|
||||
const { repo, index, analysis } = data;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<h4>
|
||||
{repo.full_name}
|
||||
{repo.language && <Badge bg="secondary" className="ms-2">{repo.language}</Badge>}
|
||||
{repo.is_fork && <Badge bg="outline-secondary" className="ms-2">fork</Badge>}
|
||||
{repo.is_archived && <Badge bg="warning" className="ms-2">archived</Badge>}
|
||||
</h4>
|
||||
{repo.description && <p className="text-muted">{repo.description}</p>}
|
||||
<small className="text-muted">
|
||||
Stars: {repo.stars} · Forks: {repo.forks}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<Row>
|
||||
<Col md={6}>
|
||||
{index && (
|
||||
<Card bg="dark" className="mb-3">
|
||||
<Card.Header>Index</Card.Header>
|
||||
<Card.Body>
|
||||
<Row>
|
||||
<Col xs={6}>
|
||||
<div><strong>{index.file_count}</strong> files</div>
|
||||
<div><strong>{index.line_count.toLocaleString()}</strong> lines</div>
|
||||
<div><strong>{index.dependency_count}</strong> dependencies</div>
|
||||
</Col>
|
||||
<Col xs={6}>
|
||||
<div><strong>{index.total_commits}</strong> commits</div>
|
||||
<div><strong>{index.identity_commits}</strong> identity commits</div>
|
||||
</Col>
|
||||
</Row>
|
||||
<div className="mt-3">
|
||||
{index.has_ci && <Badge bg="success" className="me-1">CI</Badge>}
|
||||
{index.has_tests && <Badge bg="success" className="me-1">Tests</Badge>}
|
||||
{index.has_readme && <Badge bg="success" className="me-1">README</Badge>}
|
||||
{index.has_license && <Badge bg="success" className="me-1">License</Badge>}
|
||||
{!index.has_ci && <Badge bg="secondary" className="me-1">No CI</Badge>}
|
||||
{!index.has_tests && <Badge bg="secondary" className="me-1">No Tests</Badge>}
|
||||
</div>
|
||||
{Object.keys(index.languages).length > 0 && (
|
||||
<div className="mt-3">
|
||||
<small className="text-muted">Languages</small>
|
||||
<div className="mt-1">
|
||||
{Object.entries(index.languages)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([lang, count]) => (
|
||||
<Badge key={lang} bg="secondary" className="me-1 mb-1">
|
||||
{lang}: {count}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{analysis && (
|
||||
<Card bg="dark" className="mb-3">
|
||||
<Card.Header>
|
||||
Analysis
|
||||
<small className="text-muted ms-2">({analysis.model_used})</small>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<p>{analysis.summary}</p>
|
||||
{analysis.purpose && (
|
||||
<div className="mb-2">
|
||||
<strong>Purpose:</strong> {analysis.purpose}
|
||||
</div>
|
||||
)}
|
||||
{analysis.commercial_viability && (
|
||||
<div className="mb-2">
|
||||
<strong>Commercial:</strong> {analysis.commercial_viability}
|
||||
</div>
|
||||
)}
|
||||
{analysis.technical_debt && (
|
||||
<div className="mb-2">
|
||||
<strong>Tech Debt:</strong> {analysis.technical_debt}
|
||||
</div>
|
||||
)}
|
||||
{analysis.effort_to_ship && (
|
||||
<div className="mb-2">
|
||||
<strong>Effort:</strong>{" "}
|
||||
<Badge bg="info">{analysis.effort_to_ship}</Badge>
|
||||
</div>
|
||||
)}
|
||||
{analysis.suggested_improvements.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<strong>Improvements:</strong>
|
||||
<ListGroup variant="flush" className="mt-1">
|
||||
{analysis.suggested_improvements.map((s, i) => (
|
||||
<ListGroup.Item key={i} variant="dark">{s}</ListGroup.Item>
|
||||
))}
|
||||
</ListGroup>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
)}
|
||||
</Col>
|
||||
|
||||
<Col md={6}>
|
||||
{repo.composite_score != null && (
|
||||
<Card bg="dark" className="mb-3">
|
||||
<Card.Header>Scores</Card.Header>
|
||||
<Card.Body>
|
||||
<ScoreBar label="Composite" value={repo.composite_score} />
|
||||
<hr />
|
||||
<ScoreBar label="Ownership" value={repo.ownership_score} />
|
||||
<ScoreBar label="Effort Invested" value={repo.effort_invested_score} />
|
||||
<ScoreBar label="Recency" value={repo.recency_score} />
|
||||
<ScoreBar label="Code Quality" value={repo.code_quality_score} />
|
||||
<ScoreBar label="Commercial" value={repo.commercial_score} />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Link to="/repos" className="btn btn-outline-light btn-sm mt-2">Back to Repos</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
110
dashboard/src/pages/Repos.tsx
Normal file
110
dashboard/src/pages/Repos.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Table, Badge, ProgressBar, Button, Spinner, Alert } from "react-bootstrap";
|
||||
import { fetchRepos } from "../api";
|
||||
import type { RepoSummary } from "../types";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
function scoreVariant(score: number): string {
|
||||
if (score >= 0.75) return "success";
|
||||
if (score >= 0.5) return "info";
|
||||
if (score >= 0.25) return "warning";
|
||||
return "danger";
|
||||
}
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const seconds = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (seconds < 60) return "just now";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
export default function Repos() {
|
||||
const [repos, setRepos] = useState<RepoSummary[]>([]);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetchRepos(PAGE_SIZE, offset)
|
||||
.then(setRepos)
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [offset]);
|
||||
|
||||
if (loading) return <div className="text-center mt-5"><Spinner /></div>;
|
||||
if (error) return <Alert variant="danger">{error}</Alert>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h5 className="mb-3">Repositories</h5>
|
||||
<Table variant="dark" hover size="sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Repository</th>
|
||||
<th>Language</th>
|
||||
<th>Stars</th>
|
||||
<th>Forks</th>
|
||||
<th>Tags</th>
|
||||
<th>Score</th>
|
||||
<th>Last Push</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{repos.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td><Link to={`/repos/${r.id}`}>{r.full_name}</Link></td>
|
||||
<td className="text-muted">{r.language ?? ""}</td>
|
||||
<td>{r.stars}</td>
|
||||
<td>{r.forks}</td>
|
||||
<td>
|
||||
{r.is_fork && <Badge bg="secondary" className="me-1">fork</Badge>}
|
||||
{r.is_archived && <Badge bg="warning" className="me-1">archived</Badge>}
|
||||
</td>
|
||||
<td style={{ width: "15%" }}>
|
||||
{r.composite_score != null ? (
|
||||
<ProgressBar
|
||||
now={r.composite_score * 100}
|
||||
variant={scoreVariant(r.composite_score)}
|
||||
label={`${Math.round(r.composite_score * 100)}%`}
|
||||
style={{ minWidth: "60px" }}
|
||||
/>
|
||||
) : "-"}
|
||||
</td>
|
||||
<td className="text-muted">
|
||||
{r.last_pushed_at ? timeAgo(r.last_pushed_at) : "-"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
<div className="d-flex justify-content-between">
|
||||
<Button
|
||||
variant="outline-light"
|
||||
size="sm"
|
||||
disabled={offset === 0}
|
||||
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-muted">
|
||||
{offset + 1}–{offset + repos.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline-light"
|
||||
size="sm"
|
||||
disabled={repos.length < PAGE_SIZE}
|
||||
onClick={() => setOffset(offset + PAGE_SIZE)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
73
dashboard/src/types.ts
Normal file
73
dashboard/src/types.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export interface PipelineStats {
|
||||
total_repos: number;
|
||||
indexed_repos: number;
|
||||
analyzed_repos: number;
|
||||
ranked_repos: number;
|
||||
pending_actions: number;
|
||||
}
|
||||
|
||||
export interface RepoSummary {
|
||||
id: string;
|
||||
full_name: string;
|
||||
description: string | null;
|
||||
language: string | null;
|
||||
stars: number;
|
||||
forks: number;
|
||||
is_fork: boolean;
|
||||
is_archived: boolean;
|
||||
last_pushed_at: string | null;
|
||||
composite_score: number | null;
|
||||
ownership_score: number | null;
|
||||
effort_invested_score: number | null;
|
||||
recency_score: number | null;
|
||||
code_quality_score: number | null;
|
||||
commercial_score: number | null;
|
||||
}
|
||||
|
||||
export interface RepoIndex {
|
||||
repo_id: string;
|
||||
total_commits: number;
|
||||
identity_commits: number;
|
||||
languages: Record<string, number>;
|
||||
has_ci: boolean;
|
||||
has_tests: boolean;
|
||||
has_readme: boolean;
|
||||
has_license: boolean;
|
||||
dependency_count: number;
|
||||
file_count: number;
|
||||
line_count: number;
|
||||
last_identity_commit_at: string | null;
|
||||
indexed_at: string;
|
||||
}
|
||||
|
||||
export interface Analysis {
|
||||
id: string;
|
||||
repo_id: string;
|
||||
summary: string;
|
||||
purpose: string | null;
|
||||
commercial_viability: string | null;
|
||||
technical_debt: string | null;
|
||||
suggested_improvements: string[];
|
||||
effort_to_ship: string | null;
|
||||
model_used: string;
|
||||
analyzed_at: string;
|
||||
}
|
||||
|
||||
export interface RepoDetail {
|
||||
repo: RepoSummary;
|
||||
index: RepoIndex | null;
|
||||
analysis: Analysis | null;
|
||||
}
|
||||
|
||||
export interface ActionItem {
|
||||
id: string;
|
||||
repo_id: string;
|
||||
repo_name: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
title: string;
|
||||
body: string;
|
||||
remote_url: string | null;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
}
|
||||
1
dashboard/src/vite-env.d.ts
vendored
Normal file
1
dashboard/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
20
dashboard/tsconfig.json
Normal file
20
dashboard/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
1
dashboard/tsconfig.node.tsbuildinfo
Normal file
1
dashboard/tsconfig.node.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
1
dashboard/tsconfig.tsbuildinfo
Normal file
1
dashboard/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./src/App.tsx","./src/api.ts","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/Layout.tsx","./src/pages/Actions.tsx","./src/pages/Dashboard.tsx","./src/pages/RepoDetail.tsx","./src/pages/Repos.tsx","./vite.config.ts"],"version":"5.9.3"}
|
||||
14
dashboard/vite.config.ts
Normal file
14
dashboard/vite.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:17380",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -317,6 +317,16 @@ deploy_nginx() {
|
||||
sudo systemctl enable --now step@mm.timer
|
||||
"
|
||||
|
||||
# Build and deploy frontend
|
||||
if [[ -f "$ROOT_DIR/dashboard/package.json" ]]; then
|
||||
info "building dashboard frontend"
|
||||
run bash -c "cd '$ROOT_DIR/dashboard' && npm ci --silent && npm run build"
|
||||
info "deploying dashboard to $NGINX_HOST"
|
||||
run rsync -az --rsync-path 'sudo rsync' --chown root:root \
|
||||
"$ROOT_DIR/dashboard/dist/" "$NGINX_HOST:/var/www/mm/"
|
||||
ssh_run "$NGINX_HOST" "sudo restorecon -R /var/www/mm"
|
||||
fi
|
||||
|
||||
# Deploy nginx vhost config
|
||||
run rsync -az --rsync-path 'sudo rsync' "$ROOT_DIR/asset/nginx/mm.kosherinata.conf" \
|
||||
"$NGINX_HOST:/etc/nginx/sites-available/mm.internal.conf"
|
||||
|
||||
Reference in New Issue
Block a user