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:
2026-04-23 10:05:33 +03:00
parent e38aa32172
commit 34b7449dd0
19 changed files with 2789 additions and 0 deletions

3
dashboard/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
dist/
.vite/

12
dashboard/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

25
dashboard/package.json Normal file
View 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
View 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
View 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`);
}

View 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
View 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>,
);

View 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>
)}
</>
);
}

View 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>
</>
);
}

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

View 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}&ndash;{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
View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

20
dashboard/tsconfig.json Normal file
View 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"]
}

File diff suppressed because one or more lines are too long

View 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
View 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,
},
},
},
});

View File

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