──────
Build and Push Docker Images / detect-changes (push) Successful in 11s
Build and Push Docker Images / build-auth-api (push) Successful in 1m0s
Build and Push Docker Images / build-employee-api (push) Successful in 1m2s
Build and Push Docker Images / build-transit-api (push) Successful in 1m1s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m5s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m13s
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m15s
Build and Push Docker Images / deploy-test-node (push) Successful in 3s
Build and Push Docker Images / detect-changes (push) Successful in 11s
Build and Push Docker Images / build-auth-api (push) Successful in 1m0s
Build and Push Docker Images / build-employee-api (push) Successful in 1m2s
Build and Push Docker Images / build-transit-api (push) Successful in 1m1s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m5s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m13s
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m15s
Build and Push Docker Images / deploy-test-node (push) Successful in 3s
### Summary of Changes
1. Documentation Consolidation:
• Relocated the docs/ folder and all repository .md files (README.md, DEPLOYMENT.md, DOCKER_README.md,
DOMAINS_README.md, GETTING_STARTED.md, CI_CD_PORTAINER_GUIDE.md, ADMIN_DEPLOYMENT_SUMMARY.md,
SECURITY_PRIVATE_FILES.md, handoff.md, azure-project-plan.md, plus service-specific READMEs) into .
• Updated document links in README.md.
2. AI Agent Project Map:
• Created PROJECT_MAP.md in the project root containing complete architecture diagrams, service specs, ports, DB
schemas, environment variables, and developer workflows.
• Added PROJECT_MAP.md to .gitignore.
3. Monitoring UI & Infrastructure Integration:
• Added monitoring_ui service to docker-compose.yml (port 5175).
• Configured Gitea workflow docker-build.yml for monitoring_ui builds.
• Added CORS middleware headers in index.js.
• Added nginx.conf copy step in Dockerfile.
──────
### Recommended Commit Message
docs: consolidate project documentation into monitoring_ui and add agent project map
- Move all markdown documentation files and docs/ folder into monitoring_ui/docs/
- Update documentation hub index (monitoring_ui/docs/README.md) with relative paths
- Generate comprehensive AI agent reference map (PROJECT_MAP.md) in repository root
- Add PROJECT_MAP.md and monitoring_ui build output/env overrides to .gitignore
- Integrate monitoring_ui service into docker-compose.yml and Gitea CI workflow
- Configure CORS handling in employee_api for monitoring dashboard access
──────
### Alternative Short Commit Message
refactor(docs): move docs to monitoring_ui and create AI agent PROJECT_MAP.md
This commit is contained in:
@@ -0,0 +1,746 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
const runtime =
|
||||
typeof window !== "undefined" && window.__ENV__ ? window.__ENV__ : {};
|
||||
|
||||
const configured = {
|
||||
auth:
|
||||
runtime.AUTH_API ||
|
||||
import.meta.env.VITE_AUTH_API ||
|
||||
"http://localhost:5000",
|
||||
employee:
|
||||
runtime.EMPLOYEE_API ||
|
||||
import.meta.env.VITE_EMPLOYEE_API ||
|
||||
"http://localhost:4000",
|
||||
transit:
|
||||
runtime.TRANSIT_API ||
|
||||
import.meta.env.VITE_TRANSIT_API ||
|
||||
"http://localhost:5001",
|
||||
errors:
|
||||
runtime.ERROR_LOG_API ||
|
||||
import.meta.env.VITE_ERROR_LOG_API ||
|
||||
"http://localhost:5003",
|
||||
admin:
|
||||
runtime.ADMIN_UI ||
|
||||
import.meta.env.VITE_ADMIN_UI ||
|
||||
"http://localhost:5174",
|
||||
};
|
||||
|
||||
const serviceMeta = {
|
||||
auth: {
|
||||
label: "Auth API",
|
||||
role: "Identity & tokens",
|
||||
color: "amber",
|
||||
data: "Login, JWT sessions, current user profile",
|
||||
routes: ["/login", "/me"],
|
||||
},
|
||||
employee: {
|
||||
label: "Employee API",
|
||||
role: "People operations",
|
||||
color: "teal",
|
||||
data: "Employee onboarding and employee records",
|
||||
routes: ["/health", "/createemployee"],
|
||||
},
|
||||
transit: {
|
||||
label: "Transit API",
|
||||
role: "Routes & schedules",
|
||||
color: "blue",
|
||||
data: "Schedules, routes, GPS, fleet and admin data",
|
||||
routes: [
|
||||
"/health",
|
||||
"/public/schedules/:scheduleId/progress",
|
||||
"/schedule/daily?date=YYYY-MM-DD",
|
||||
"/admin/stops",
|
||||
],
|
||||
},
|
||||
errors: {
|
||||
label: "Error Log API",
|
||||
role: "Incident archive",
|
||||
color: "coral",
|
||||
data: "Centralized bad requests and service errors",
|
||||
routes: ["/health", "/error_log?limit=100"],
|
||||
},
|
||||
admin: {
|
||||
label: "Admin UI",
|
||||
role: "Operator surface",
|
||||
color: "violet",
|
||||
data: "The deployed React administration console",
|
||||
routes: ["/health"],
|
||||
},
|
||||
};
|
||||
|
||||
const healthPaths = {
|
||||
auth: "/me",
|
||||
employee: "/health",
|
||||
transit: "/health",
|
||||
errors: "/health",
|
||||
admin: "/health",
|
||||
};
|
||||
const testerRoutes = [
|
||||
{ service: "auth", method: "GET", path: "/me", label: "Current session" },
|
||||
{
|
||||
service: "employee",
|
||||
method: "GET",
|
||||
path: "/health",
|
||||
label: "Employee health",
|
||||
},
|
||||
{
|
||||
service: "transit",
|
||||
method: "GET",
|
||||
path: "/health",
|
||||
label: "Transit health",
|
||||
},
|
||||
{
|
||||
service: "transit",
|
||||
method: "GET",
|
||||
path: "/public/schedules/1/progress",
|
||||
label: "Public schedule progress",
|
||||
},
|
||||
{
|
||||
service: "transit",
|
||||
method: "GET",
|
||||
path: "/schedule/daily?date=2026-09-20",
|
||||
label: "Driver daily schedule",
|
||||
},
|
||||
{
|
||||
service: "errors",
|
||||
method: "GET",
|
||||
path: "/error_log?limit=25",
|
||||
label: "Recent error log",
|
||||
},
|
||||
];
|
||||
|
||||
function pretty(value) {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [urls, setUrls] = useState(
|
||||
() =>
|
||||
JSON.parse(localStorage.getItem("transit-monitor-urls") || "null") ||
|
||||
configured,
|
||||
);
|
||||
const [token, setToken] = useState(
|
||||
() => localStorage.getItem("transit-monitor-token") || "",
|
||||
);
|
||||
const [health, setHealth] = useState({});
|
||||
const [history, setHistory] = useState({});
|
||||
const [errors, setErrors] = useState([]);
|
||||
const [errorState, setErrorState] = useState("idle");
|
||||
const [interval, setIntervalValue] = useState(30);
|
||||
const [lastRefresh, setLastRefresh] = useState(null);
|
||||
const [activeView, setActiveView] = useState("overview");
|
||||
const [tester, setTester] = useState({
|
||||
service: "transit",
|
||||
method: "GET",
|
||||
path: "/health",
|
||||
body: "",
|
||||
});
|
||||
const [testerResult, setTesterResult] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const checkService = useCallback(
|
||||
async (key) => {
|
||||
const started = performance.now();
|
||||
const headers = token ? { Authorization: `Bearer ${token}` } : {};
|
||||
try {
|
||||
const response = await fetch(`${urls[key]}${healthPaths[key]}`, {
|
||||
headers,
|
||||
});
|
||||
const body = await response.json().catch(() => null);
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
body,
|
||||
latency: Math.round(performance.now() - started),
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
body: { error: error.message },
|
||||
latency: Math.round(performance.now() - started),
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
},
|
||||
[token, urls],
|
||||
);
|
||||
|
||||
const refreshHealth = useCallback(async () => {
|
||||
const results = await Promise.all(
|
||||
Object.keys(serviceMeta).map(async (key) => [
|
||||
key,
|
||||
await checkService(key),
|
||||
]),
|
||||
);
|
||||
const next = Object.fromEntries(results);
|
||||
setHealth(next);
|
||||
setHistory((current) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(next).map(([key, value]) => [
|
||||
key,
|
||||
[...(current[key] || []), value.latency].slice(-18),
|
||||
]),
|
||||
),
|
||||
);
|
||||
setLastRefresh(new Date());
|
||||
}, [checkService]);
|
||||
|
||||
const loadErrors = useCallback(async () => {
|
||||
if (!token) {
|
||||
setErrors([]);
|
||||
setErrorState("missing-token");
|
||||
return;
|
||||
}
|
||||
setErrorState("loading");
|
||||
try {
|
||||
const response = await fetch(`${urls.errors}/error_log?limit=80`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok)
|
||||
throw new Error(body.error || `HTTP ${response.status}`);
|
||||
setErrors(body.errors || []);
|
||||
setErrorState("ready");
|
||||
} catch (error) {
|
||||
setErrorState(error.message);
|
||||
}
|
||||
}, [token, urls.errors]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshHealth();
|
||||
const timer = setInterval(refreshHealth, interval * 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [refreshHealth, interval]);
|
||||
useEffect(() => {
|
||||
if (activeView === "incidents") loadErrors();
|
||||
}, [activeView, loadErrors]);
|
||||
|
||||
const saveSettings = (nextUrls, nextToken) => {
|
||||
setUrls(nextUrls);
|
||||
setToken(nextToken);
|
||||
localStorage.setItem("transit-monitor-urls", JSON.stringify(nextUrls));
|
||||
localStorage.setItem("transit-monitor-token", nextToken);
|
||||
};
|
||||
|
||||
const runRequest = async () => {
|
||||
const route = testerRoutes.find(
|
||||
(item) =>
|
||||
item.service === tester.service &&
|
||||
item.method === tester.method &&
|
||||
item.path === tester.path,
|
||||
);
|
||||
const isMutation = tester.method !== "GET";
|
||||
if (
|
||||
isMutation &&
|
||||
!window.confirm("This request can change data. Continue?")
|
||||
)
|
||||
return;
|
||||
setBusy(true);
|
||||
setTesterResult(null);
|
||||
const started = performance.now();
|
||||
try {
|
||||
const response = await fetch(`${urls[tester.service]}${tester.path}`, {
|
||||
method: tester.method,
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(tester.body ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
body: tester.body || undefined,
|
||||
});
|
||||
const text = await response.text();
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
body = text;
|
||||
}
|
||||
setTesterResult({
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
latency: Math.round(performance.now() - started),
|
||||
body,
|
||||
label: route?.label,
|
||||
});
|
||||
} catch (error) {
|
||||
setTesterResult({
|
||||
ok: false,
|
||||
status: 0,
|
||||
latency: Math.round(performance.now() - started),
|
||||
body: { error: error.message },
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const healthyCount = Object.values(health).filter((item) => item.ok).length;
|
||||
const configuredCount = Object.keys(serviceMeta).length;
|
||||
const avgLatency = Math.round(
|
||||
Object.values(health).reduce((sum, item) => sum + (item.latency || 0), 0) /
|
||||
Math.max(Object.keys(health).length, 1),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="topbar">
|
||||
<div className="brand">
|
||||
<span className="brand-mark">X</span>
|
||||
<div>
|
||||
<strong>TRANSIT CONTROL ROOM</strong>
|
||||
<span>service observability</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="top-actions">
|
||||
<span className="live-dot" /> Live polling{" "}
|
||||
<select
|
||||
value={interval}
|
||||
onChange={(event) => setIntervalValue(Number(event.target.value))}
|
||||
>
|
||||
<option value="15">15 sec</option>
|
||||
<option value="30">30 sec</option>
|
||||
<option value="60">1 min</option>
|
||||
</select>
|
||||
<button className="ghost-button" onClick={refreshHealth}>
|
||||
Refresh now
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<section className="hero-row">
|
||||
<div>
|
||||
<p className="eyebrow">
|
||||
SYSTEM PULSE /{" "}
|
||||
{lastRefresh ? lastRefresh.toLocaleTimeString() : "CONNECTING"}
|
||||
</p>
|
||||
<h1>Know what is moving.</h1>
|
||||
<p className="hero-copy">
|
||||
A live field view across the APIs that keep XTransit running.
|
||||
</p>
|
||||
</div>
|
||||
<div className="pulse-stat">
|
||||
<span>Healthy services</span>
|
||||
<strong>
|
||||
{healthyCount}
|
||||
<small>/{configuredCount}</small>
|
||||
</strong>
|
||||
<em>{avgLatency} ms avg response</em>
|
||||
</div>
|
||||
</section>
|
||||
<nav className="tabs">
|
||||
<button
|
||||
className={activeView === "overview" ? "active" : ""}
|
||||
onClick={() => setActiveView("overview")}
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
className={activeView === "catalog" ? "active" : ""}
|
||||
onClick={() => setActiveView("catalog")}
|
||||
>
|
||||
Service catalog
|
||||
</button>
|
||||
<button
|
||||
className={activeView === "incidents" ? "active" : ""}
|
||||
onClick={() => setActiveView("incidents")}
|
||||
>
|
||||
Errors & bad requests <b>{errors.length || ""}</b>
|
||||
</button>
|
||||
<button
|
||||
className={activeView === "tester" ? "active" : ""}
|
||||
onClick={() => setActiveView("tester")}
|
||||
>
|
||||
Request lab
|
||||
</button>
|
||||
<button
|
||||
className={activeView === "settings" ? "active" : ""}
|
||||
onClick={() => setActiveView("settings")}
|
||||
>
|
||||
Connections
|
||||
</button>
|
||||
</nav>
|
||||
{activeView === "overview" && (
|
||||
<>
|
||||
<section className="service-grid">
|
||||
{Object.entries(serviceMeta).map(([key, meta]) => (
|
||||
<ServiceCard
|
||||
key={key}
|
||||
serviceKey={key}
|
||||
meta={meta}
|
||||
health={health[key]}
|
||||
history={history[key] || []}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
<section className="lower-grid">
|
||||
<div className="panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">DATA MAP</p>
|
||||
<h2>What each service serves</h2>
|
||||
</div>
|
||||
<span className="panel-count">
|
||||
{configuredCount} surfaces
|
||||
</span>
|
||||
</div>
|
||||
<div className="data-list">
|
||||
{Object.entries(serviceMeta).map(([key, meta]) => (
|
||||
<div className="data-row" key={key}>
|
||||
<span className={`service-dot ${meta.color}`} />
|
||||
<strong>{meta.label}</strong>
|
||||
<span>{meta.data}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<QuickNotes />
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
{activeView === "catalog" && <Catalog />}
|
||||
{activeView === "incidents" && (
|
||||
<Incidents errors={errors} state={errorState} reload={loadErrors} />
|
||||
)}
|
||||
{activeView === "tester" && (
|
||||
<RequestLab
|
||||
tester={tester}
|
||||
setTester={setTester}
|
||||
result={testerResult}
|
||||
run={runRequest}
|
||||
busy={busy}
|
||||
token={token}
|
||||
/>
|
||||
)}
|
||||
{activeView === "settings" && (
|
||||
<Connections urls={urls} token={token} save={saveSettings} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceCard({ serviceKey, meta, health, history }) {
|
||||
const status = !health
|
||||
? "checking"
|
||||
: health.ok
|
||||
? "healthy"
|
||||
: health.status === 0
|
||||
? "offline"
|
||||
: "degraded";
|
||||
return (
|
||||
<article className={`service-card ${meta.color}`}>
|
||||
<div className="card-top">
|
||||
<div>
|
||||
<span className={`service-icon ${meta.color}`}>
|
||||
{meta.label.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
<h3>{meta.label}</h3>
|
||||
<p>{meta.role}</p>
|
||||
</div>
|
||||
<span className={`status-pill ${status}`}>
|
||||
<i />
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="metric-line">
|
||||
<span>Response time</span>
|
||||
<strong>{health ? `${health.latency} ms` : "--"}</strong>
|
||||
</div>
|
||||
<div className="sparkline">
|
||||
{history.length > 1 ? (
|
||||
history.map((value, index) => (
|
||||
<span
|
||||
key={`${value}-${index}`}
|
||||
style={{ height: `${Math.max(10, Math.min(100, value / 4))}%` }}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<em>Waiting for first reading</em>
|
||||
)}
|
||||
</div>
|
||||
{health?.body && (
|
||||
<details>
|
||||
<summary>Health payload</summary>
|
||||
<pre>{pretty(health.body)}</pre>
|
||||
</details>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickNotes() {
|
||||
return (
|
||||
<div className="panel suggestion-panel">
|
||||
<p className="eyebrow">OPERATOR NOTES</p>
|
||||
<h2>Useful next signals</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Database reachability</strong>
|
||||
<span>
|
||||
Transit health already reports DB status. Employee health currently
|
||||
only proves the process is reachable.
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Auth-aware checks</strong>
|
||||
<span>
|
||||
Paste an admin JWT in Connections to unlock /me and error history.
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Safer testing</strong>
|
||||
<span>
|
||||
The request lab is intentionally allowlisted, with confirmation
|
||||
before writes.
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function Catalog() {
|
||||
return (
|
||||
<section className="panel catalog-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">ENDPOINT ATLAS</p>
|
||||
<h2>Known API surfaces</h2>
|
||||
</div>
|
||||
<span className="panel-count">read-only reference</span>
|
||||
</div>
|
||||
{Object.entries(serviceMeta).map(([key, meta]) => (
|
||||
<div className="catalog-block" key={key}>
|
||||
<div>
|
||||
<span className={`service-dot ${meta.color}`} />
|
||||
<strong>{meta.label}</strong>
|
||||
<p>{meta.data}</p>
|
||||
</div>
|
||||
<div className="route-chips">
|
||||
{meta.routes.map((route) => (
|
||||
<code key={route}>{route}</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
function Incidents({ errors, state, reload }) {
|
||||
return (
|
||||
<section className="panel incidents-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">CENTRAL ERROR LOG</p>
|
||||
<h2>Errors & bad requests</h2>
|
||||
</div>
|
||||
<button className="ghost-button" onClick={reload}>
|
||||
Reload log
|
||||
</button>
|
||||
</div>
|
||||
{state === "missing-token" && (
|
||||
<Empty
|
||||
title="Admin token required"
|
||||
text="Add a JWT in Connections to read the protected error log."
|
||||
/>
|
||||
)}
|
||||
{state !== "missing-token" && state !== "ready" && state !== "idle" && (
|
||||
<Empty title="Could not load error log" text={state} />
|
||||
)}
|
||||
{state === "ready" && !errors.length && (
|
||||
<Empty
|
||||
title="No incidents returned"
|
||||
text="The error log is clear for the selected window."
|
||||
/>
|
||||
)}
|
||||
{errors.length > 0 && (
|
||||
<div className="incident-table">
|
||||
<div className="table-head">
|
||||
<span>Time</span>
|
||||
<span>Service / route</span>
|
||||
<span>Error</span>
|
||||
</div>
|
||||
{errors.map((item) => (
|
||||
<div className="table-row" key={item.id}>
|
||||
<time>{new Date(item.date).toLocaleString()}</time>
|
||||
<strong>
|
||||
{item.service}
|
||||
<small>{item.partofservice || "unspecified route"}</small>
|
||||
</strong>
|
||||
<span>{item.error}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
function Empty({ title, text }) {
|
||||
return (
|
||||
<div className="empty">
|
||||
<strong>{title}</strong>
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function RequestLab({ tester, setTester, result, run, busy, token }) {
|
||||
const matching = testerRoutes.filter(
|
||||
(route) => route.service === tester.service,
|
||||
);
|
||||
return (
|
||||
<section className="lab-grid">
|
||||
<div className="panel request-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">CONTROLLED EXECUTION</p>
|
||||
<h2>Live request lab</h2>
|
||||
</div>
|
||||
<span className="panel-count">
|
||||
{token ? "token attached" : "anonymous"}
|
||||
</span>
|
||||
</div>
|
||||
<label>
|
||||
Service
|
||||
<select
|
||||
value={tester.service}
|
||||
onChange={(event) => {
|
||||
const route = testerRoutes.find(
|
||||
(item) => item.service === event.target.value,
|
||||
);
|
||||
setTester({
|
||||
...tester,
|
||||
service: event.target.value,
|
||||
method: route.method,
|
||||
path: route.path,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{Object.entries(serviceMeta)
|
||||
.filter(([key]) => key !== "admin")
|
||||
.map(([key, meta]) => (
|
||||
<option key={key} value={key}>
|
||||
{meta.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Endpoint
|
||||
<select
|
||||
value={`${tester.method} ${tester.path}`}
|
||||
onChange={(event) => {
|
||||
const route = matching.find(
|
||||
(item) => `${item.method} ${item.path}` === event.target.value,
|
||||
);
|
||||
setTester({ ...tester, method: route.method, path: route.path });
|
||||
}}
|
||||
>
|
||||
{matching.map((route) => (
|
||||
<option key={`${route.method} ${route.path}`}>
|
||||
{route.method} {route.path}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
JSON body{" "}
|
||||
<textarea
|
||||
placeholder="Optional request body"
|
||||
value={tester.body}
|
||||
onChange={(event) =>
|
||||
setTester({ ...tester, body: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<button className="run-button" onClick={run} disabled={busy}>
|
||||
{busy ? "Running..." : "Run request"}
|
||||
</button>
|
||||
<p className="helper">
|
||||
Requests go directly from this browser to the configured service.
|
||||
Write routes require a confirmation.
|
||||
</p>
|
||||
</div>
|
||||
<div className="panel result-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">RESPONSE</p>
|
||||
<h2>
|
||||
{result
|
||||
? `${result.status || "Network"} · ${result.latency} ms`
|
||||
: "Awaiting a request"}
|
||||
</h2>
|
||||
</div>
|
||||
{result && (
|
||||
<span
|
||||
className={`status-pill ${result.ok ? "healthy" : "degraded"}`}
|
||||
>
|
||||
<i />
|
||||
{result.ok ? "success" : "failed"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{result ? (
|
||||
<pre className="response-code">{pretty(result.body)}</pre>
|
||||
) : (
|
||||
<div className="result-placeholder">
|
||||
Pick a known route and execute it to inspect the live response.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
function Connections({ urls, token, save }) {
|
||||
const [draft, setDraft] = useState(urls);
|
||||
const [draftToken, setDraftToken] = useState(token);
|
||||
return (
|
||||
<section className="panel settings-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">RUNTIME CONFIGURATION</p>
|
||||
<h2>Connections</h2>
|
||||
</div>
|
||||
<span className="panel-count">stored locally</span>
|
||||
</div>
|
||||
<p className="settings-intro">
|
||||
Point the dashboard at local Docker ports or deployed domains. Nothing
|
||||
here is sent anywhere except the service you choose to call.
|
||||
</p>
|
||||
<div className="connection-grid">
|
||||
{Object.entries(serviceMeta).map(([key, meta]) => (
|
||||
<label key={key}>
|
||||
{meta.label}
|
||||
<input
|
||||
value={draft[key]}
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, [key]: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label>
|
||||
Admin JWT token
|
||||
<input
|
||||
type="password"
|
||||
value={draftToken}
|
||||
onChange={(event) => setDraftToken(event.target.value)}
|
||||
placeholder="eyJ..."
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="run-button save-button"
|
||||
onClick={() => save(draft, draftToken)}
|
||||
>
|
||||
Save connections
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.jsx";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,670 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700;800&display=swap");
|
||||
:root {
|
||||
font-family: Manrope, sans-serif;
|
||||
color: #e7eeee;
|
||||
background: #101517;
|
||||
font-synthesis: none;
|
||||
--bg: #101517;
|
||||
--panel: #172022;
|
||||
--line: #2b393a;
|
||||
--muted: #91a2a1;
|
||||
--mint: #8ae8c2;
|
||||
--amber: #f2bd70;
|
||||
--blue: #7eb7f5;
|
||||
--coral: #f58d7d;
|
||||
--violet: #c1a5fa;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
background:
|
||||
radial-gradient(circle at 82% -10%, #24403b 0, transparent 32%),
|
||||
linear-gradient(135deg, #101517 0%, #111b1d 54%, #182020 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
.app-shell {
|
||||
max-width: 1440px;
|
||||
margin: auto;
|
||||
padding: 0 42px 70px;
|
||||
}
|
||||
.topbar {
|
||||
height: 82px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid var(--mint);
|
||||
color: var(--mint);
|
||||
font-weight: 800;
|
||||
font-size: 20px;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.brand-mark::first-letter {
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
.brand strong,
|
||||
.brand span {
|
||||
display: block;
|
||||
}
|
||||
.brand strong {
|
||||
font:
|
||||
600 12px "DM Mono",
|
||||
monospace;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
.brand div span {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.top-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
color: var(--muted);
|
||||
font:
|
||||
500 12px "DM Mono",
|
||||
monospace;
|
||||
}
|
||||
.live-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--mint);
|
||||
box-shadow: 0 0 14px var(--mint);
|
||||
}
|
||||
select,
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: #111a1c;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
padding: 10px 12px;
|
||||
outline: none;
|
||||
}
|
||||
select:focus,
|
||||
input:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--mint);
|
||||
}
|
||||
.top-actions select {
|
||||
padding: 7px 9px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.ghost-button {
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: #d3dddd;
|
||||
padding: 9px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ghost-button:hover {
|
||||
border-color: var(--mint);
|
||||
color: var(--mint);
|
||||
}
|
||||
.hero-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: end;
|
||||
padding: 68px 0 38px;
|
||||
}
|
||||
.eyebrow {
|
||||
font:
|
||||
500 10px "DM Mono",
|
||||
monospace;
|
||||
color: var(--mint);
|
||||
letter-spacing: 0.16em;
|
||||
margin: 0 0 13px;
|
||||
}
|
||||
.hero-row h1 {
|
||||
font-size: clamp(38px, 6vw, 76px);
|
||||
line-height: 0.98;
|
||||
letter-spacing: -0.04em;
|
||||
margin: 0;
|
||||
max-width: 680px;
|
||||
}
|
||||
.hero-copy {
|
||||
color: var(--muted);
|
||||
font-size: 16px;
|
||||
margin: 20px 0 0;
|
||||
}
|
||||
.pulse-stat {
|
||||
border-left: 1px solid var(--line);
|
||||
padding-left: 25px;
|
||||
min-width: 180px;
|
||||
}
|
||||
.pulse-stat span,
|
||||
.pulse-stat em {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font:
|
||||
11px "DM Mono",
|
||||
monospace;
|
||||
}
|
||||
.pulse-stat strong {
|
||||
display: block;
|
||||
font-size: 52px;
|
||||
line-height: 1.1;
|
||||
margin: 7px 0;
|
||||
color: var(--mint);
|
||||
}
|
||||
.pulse-stat small {
|
||||
font-size: 20px;
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.pulse-stat em {
|
||||
font-style: normal;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin-bottom: 28px;
|
||||
overflow: auto;
|
||||
}
|
||||
.tabs button {
|
||||
white-space: nowrap;
|
||||
background: none;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--muted);
|
||||
padding: 13px 0;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
}
|
||||
.tabs button.active,
|
||||
.tabs button:hover {
|
||||
color: #fff;
|
||||
border-color: var(--mint);
|
||||
}
|
||||
.tabs b {
|
||||
color: var(--coral);
|
||||
margin-left: 5px;
|
||||
}
|
||||
.service-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.service-card,
|
||||
.panel {
|
||||
background: rgba(23, 32, 34, 0.88);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.service-card {
|
||||
padding: 19px;
|
||||
min-height: 250px;
|
||||
border-top: 2px solid var(--card-color);
|
||||
}
|
||||
.service-card.amber {
|
||||
--card-color: var(--amber);
|
||||
}
|
||||
.service-card.teal {
|
||||
--card-color: var(--mint);
|
||||
}
|
||||
.service-card.blue {
|
||||
--card-color: var(--blue);
|
||||
}
|
||||
.service-card.coral {
|
||||
--card-color: var(--coral);
|
||||
}
|
||||
.service-card.violet {
|
||||
--card-color: var(--violet);
|
||||
}
|
||||
.card-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.service-icon {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 3px;
|
||||
font:
|
||||
500 10px "DM Mono",
|
||||
monospace;
|
||||
background: #293332;
|
||||
color: var(--card-color);
|
||||
}
|
||||
.card-top h3 {
|
||||
font-size: 14px;
|
||||
margin: 13px 0 3px;
|
||||
}
|
||||
.card-top p {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
margin: 0;
|
||||
}
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 22px;
|
||||
border-radius: 20px;
|
||||
padding: 0 8px;
|
||||
font:
|
||||
500 10px "DM Mono",
|
||||
monospace;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.status-pill i {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
.status-pill.healthy {
|
||||
color: var(--mint);
|
||||
background: #17372f;
|
||||
}
|
||||
.status-pill.degraded {
|
||||
color: var(--coral);
|
||||
background: #3b2523;
|
||||
}
|
||||
.status-pill.offline {
|
||||
color: #f17c76;
|
||||
background: #3b2523;
|
||||
}
|
||||
.status-pill.checking {
|
||||
color: var(--amber);
|
||||
background: #3c3020;
|
||||
}
|
||||
.metric-line {
|
||||
border-top: 1px solid var(--line);
|
||||
margin-top: 25px;
|
||||
padding-top: 13px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--muted);
|
||||
font:
|
||||
10px "DM Mono",
|
||||
monospace;
|
||||
}
|
||||
.metric-line strong {
|
||||
color: #e8eeee;
|
||||
font-weight: 500;
|
||||
}
|
||||
.sparkline {
|
||||
height: 45px;
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 3px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.sparkline span {
|
||||
display: block;
|
||||
flex: 1;
|
||||
min-width: 3px;
|
||||
background: var(--card-color);
|
||||
opacity: 0.75;
|
||||
border-radius: 1px;
|
||||
}
|
||||
.sparkline em {
|
||||
font:
|
||||
10px "DM Mono",
|
||||
monospace;
|
||||
color: #607170;
|
||||
}
|
||||
.service-card details {
|
||||
margin-top: 12px;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
.service-card summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
.service-card pre {
|
||||
max-height: 100px;
|
||||
overflow: auto;
|
||||
color: #c6d5d3;
|
||||
font:
|
||||
10px "DM Mono",
|
||||
monospace;
|
||||
}
|
||||
.lower-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1fr;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.panel {
|
||||
padding: 25px;
|
||||
}
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
gap: 20px;
|
||||
margin-bottom: 21px;
|
||||
}
|
||||
.panel h2 {
|
||||
font-size: 19px;
|
||||
margin: 0;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.panel-count {
|
||||
font:
|
||||
10px "DM Mono",
|
||||
monospace;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--line);
|
||||
padding: 6px 8px;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.data-list {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.data-row {
|
||||
display: grid;
|
||||
grid-template-columns: 10px 150px 1fr;
|
||||
gap: 11px;
|
||||
align-items: center;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 12px;
|
||||
}
|
||||
.data-row > span:last-child {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.service-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.service-dot.amber {
|
||||
background: var(--amber);
|
||||
}
|
||||
.service-dot.teal {
|
||||
background: var(--mint);
|
||||
}
|
||||
.service-dot.blue {
|
||||
background: var(--blue);
|
||||
}
|
||||
.service-dot.coral {
|
||||
background: var(--coral);
|
||||
}
|
||||
.service-dot.violet {
|
||||
background: var(--violet);
|
||||
}
|
||||
.suggestion-panel ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.suggestion-panel li {
|
||||
padding: 13px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.suggestion-panel li strong,
|
||||
.suggestion-panel li span {
|
||||
display: block;
|
||||
}
|
||||
.suggestion-panel li strong {
|
||||
font-size: 12px;
|
||||
}
|
||||
.suggestion-panel li span {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.catalog-panel,
|
||||
.incidents-panel,
|
||||
.settings-panel {
|
||||
min-height: 500px;
|
||||
}
|
||||
.catalog-block {
|
||||
display: grid;
|
||||
grid-template-columns: 270px 1fr;
|
||||
gap: 20px;
|
||||
padding: 20px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.catalog-block > div:first-child {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
align-items: start;
|
||||
}
|
||||
.catalog-block p {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
margin: 7px 0 0 16px;
|
||||
}
|
||||
.route-chips {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.route-chips code {
|
||||
font:
|
||||
11px "DM Mono",
|
||||
monospace;
|
||||
color: #c8d8d5;
|
||||
border: 1px solid var(--line);
|
||||
background: #111a1c;
|
||||
border-radius: 3px;
|
||||
padding: 8px;
|
||||
}
|
||||
.table-head,
|
||||
.table-row {
|
||||
display: grid;
|
||||
grid-template-columns: 170px 220px 1fr;
|
||||
gap: 20px;
|
||||
padding: 13px 0;
|
||||
}
|
||||
.table-head {
|
||||
color: var(--muted);
|
||||
font:
|
||||
10px "DM Mono",
|
||||
monospace;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.table-row {
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid #243131;
|
||||
}
|
||||
.table-row time,
|
||||
.table-row > span {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.table-row strong small {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font:
|
||||
10px "DM Mono",
|
||||
monospace;
|
||||
font-weight: 400;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.empty,
|
||||
.result-placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
text-align: center;
|
||||
min-height: 250px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
.empty strong {
|
||||
color: #e8eeee;
|
||||
}
|
||||
.lab-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.25fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.request-panel label,
|
||||
.settings-panel label {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin: 17px 0;
|
||||
color: var(--muted);
|
||||
font:
|
||||
10px "DM Mono",
|
||||
monospace;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.request-panel select,
|
||||
.request-panel textarea,
|
||||
.settings-panel input {
|
||||
width: 100%;
|
||||
}
|
||||
.request-panel textarea {
|
||||
height: 130px;
|
||||
resize: vertical;
|
||||
}
|
||||
.run-button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: var(--mint);
|
||||
color: #10201c;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
.run-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: wait;
|
||||
}
|
||||
.helper,
|
||||
.settings-intro {
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--muted);
|
||||
}
|
||||
.response-code {
|
||||
background: #0d1415;
|
||||
border: 1px solid var(--line);
|
||||
padding: 18px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
min-height: 320px;
|
||||
max-height: 520px;
|
||||
overflow: auto;
|
||||
color: #b8e9d6;
|
||||
font:
|
||||
11px "DM Mono",
|
||||
monospace;
|
||||
}
|
||||
.connection-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0 18px;
|
||||
}
|
||||
.save-button {
|
||||
max-width: 180px;
|
||||
}
|
||||
.incidents-panel .panel-heading {
|
||||
align-items: center;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.service-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
.service-card:last-child {
|
||||
grid-column: auto;
|
||||
}
|
||||
.hero-row {
|
||||
align-items: start;
|
||||
gap: 30px;
|
||||
}
|
||||
.lower-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.pulse-stat {
|
||||
min-width: 150px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.app-shell {
|
||||
padding: 0 18px 40px;
|
||||
}
|
||||
.topbar {
|
||||
height: auto;
|
||||
padding: 18px 0;
|
||||
align-items: start;
|
||||
gap: 14px;
|
||||
flex-direction: column;
|
||||
}
|
||||
.top-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.hero-row {
|
||||
padding: 43px 0 28px;
|
||||
display: block;
|
||||
}
|
||||
.pulse-stat {
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 15px 0 0;
|
||||
margin-top: 28px;
|
||||
}
|
||||
.pulse-stat strong {
|
||||
font-size: 42px;
|
||||
}
|
||||
.service-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.service-card {
|
||||
min-height: 230px;
|
||||
padding: 14px;
|
||||
}
|
||||
.card-top {
|
||||
display: block;
|
||||
}
|
||||
.status-pill {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.catalog-block,
|
||||
.lab-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.table-head,
|
||||
.table-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 5px;
|
||||
}
|
||||
.table-head {
|
||||
display: none;
|
||||
}
|
||||
.table-row {
|
||||
padding: 15px 0;
|
||||
}
|
||||
.connection-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.tabs {
|
||||
gap: 18px;
|
||||
}
|
||||
.panel {
|
||||
padding: 18px;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user