Added environment manager and updated UI

This commit is contained in:
2026-06-24 19:23:50 +02:00
parent 594834908c
commit 0614ae82f5
7 changed files with 783 additions and 98 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
<!-- NuGet package metadata --> <!-- NuGet package metadata -->
<PackageId>EonaCat.DoxaApi</PackageId> <PackageId>EonaCat.DoxaApi</PackageId>
<Version>0.0.7</Version> <Version>0.0.8</Version>
<Authors>EonaCat (Jeroen Saey)</Authors> <Authors>EonaCat (Jeroen Saey)</Authors>
<Description>A modern, self-contained, dependency-free API documentation UI for ASP.NET Core with a built-in auth manager, mock server, server-side request console, multi-language code generation, and spec diffing.</Description> <Description>A modern, self-contained, dependency-free API documentation UI for ASP.NET Core with a built-in auth manager, mock server, server-side request console, multi-language code generation, and spec diffing.</Description>
<PackageTags>openapi;swagger;documentation;api;aspnetcore;doxa;docs;mock-server;oauth2;codegen;Jeroen;Saey;EonaCat;Scalar;Redoc;Postman;EchoAPI;</PackageTags> <PackageTags>openapi;swagger;documentation;api;aspnetcore;doxa;docs;mock-server;oauth2;codegen;Jeroen;Saey;EonaCat;Scalar;Redoc;Postman;EchoAPI;</PackageTags>
@@ -166,6 +166,29 @@ namespace EonaCat.DoxaApi.Middleware
}); });
} }
// Add analytics endpoint if metrics collector is registered
if (metricsCollector != null)
{
app.Map($"{prefix}/analytics", analyticsApp =>
{
analyticsApp.Run(async context =>
{
if (!HttpMethods.IsGet(context.Request.Method))
{
context.Response.StatusCode = 405;
context.Response.Headers["Allow"] = "GET";
await context.Response.WriteAsync("Method Not Allowed - use GET");
return;
}
var dashboard = metricsCollector.GetDashboard();
context.Response.ContentType = "application/json; charset=utf-8";
var json = JsonHelper.ToJson(dashboard, _writeOptions);
await context.Response.WriteAsync(json, Encoding.UTF8);
});
});
}
app.Map(prefix, uiApp => app.Map(prefix, uiApp =>
{ {
uiApp.Run(async context => uiApp.Run(async context =>
@@ -203,29 +226,6 @@ namespace EonaCat.DoxaApi.Middleware
}); });
}); });
// Add analytics endpoint if metrics collector is registered
if (metricsCollector != null)
{
app.Map(prefix + "/analytics", analyticsApp =>
{
analyticsApp.Run(async context =>
{
if (!HttpMethods.IsGet(context.Request.Method))
{
context.Response.StatusCode = 405;
context.Response.Headers["Allow"] = "GET";
await context.Response.WriteAsync("Method Not Allowed - use GET");
return;
}
var dashboard = metricsCollector.GetDashboard();
context.Response.ContentType = "application/json; charset=utf-8";
var json = JsonHelper.ToJson(dashboard, _writeOptions);
await context.Response.WriteAsync(json, Encoding.UTF8);
});
});
}
return app; return app;
} }
+304
View File
@@ -3322,3 +3322,307 @@ code {
flex: 1; flex: 1;
} }
} }
.env-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,.65);
backdrop-filter: blur(6px);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
animation: fadeIn .15s ease;
}
.env-modal {
width: min(850px, 92vw);
max-height: 90vh;
overflow: auto;
background: #1f1f23;
color: #fff;
border-radius: 18px;
border: 1px solid #35353a;
box-shadow: 0 24px 80px rgba(0,0,0,.45), 0 0 0 1px rgba(255,255,255,.03);
animation: scaleIn .18s ease;
}
.env-modal-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 24px;
border-bottom: 1px solid #35353a;
}
.env-modal-header h2 {
margin: 0;
font-size: 22px;
}
.env-subtitle {
margin-top: 6px;
color: #9aa0aa;
font-size: 13px;
}
.env-close-btn {
width: 36px;
height: 36px;
border-radius: 10px;
border: none;
cursor: pointer;
background: transparent;
color: #aaa;
font-size: 16px;
}
.env-close-btn:hover {
background: rgba(255,255,255,.08);
color: white;
}
.env-modal-body {
padding: 24px;
}
.env-section-title {
font-size: 13px;
font-weight: 600;
color: #9aa0aa;
text-transform: uppercase;
letter-spacing: .08em;
margin-bottom: 12px;
}
.env-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.env-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-radius: 14px;
border: 1px solid #35353a;
transition: .15s ease;
cursor: pointer;
}
.env-card:hover {
border-color: #5a9cff;
transform: translateY(-1px);
}
.env-card.active {
border-color: #5a9cff;
background: rgba(90,156,255,.12);
}
.env-card-left {
display: flex;
gap: 14px;
align-items: center;
}
.env-name {
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
}
.env-url {
margin-top: 4px;
color: #9aa0aa;
font-size: 13px;
}
.env-badge {
padding: 3px 8px;
border-radius: 999px;
font-size: 11px;
background: #5a9cff;
color: white;
}
.env-actions {
display: flex;
gap: 8px;
}
.icon-btn {
width: 34px;
height: 34px;
border-radius: 10px;
border: 1px solid #35353a;
background: transparent;
color: white;
cursor: pointer;
}
.icon-btn:hover {
background: rgba(255,255,255,.08);
}
.icon-btn.danger:hover {
background: rgba(255,80,80,.15);
}
.try-header-main {
display: flex;
flex-direction: column;
gap: 4px;
}
.try-subtitle {
color: var(--text-2);
font-size: 13px;
}
.try-env-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--bg-2);
}
.try-env-info {
min-width: 0;
flex: 1;
}
.try-env-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: .08em;
color: var(--text-2);
margin-bottom: 6px;
}
.try-env-main {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.try-env-badge {
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
background: rgba(59,130,246,.15);
color: #60a5fa;
border: 1px solid rgba(59,130,246,.25);
}
.try-env-url {
font-family: var(--font-mono);
font-size: 13px;
color: var(--text-2);
overflow: hidden;
text-overflow: ellipsis;
}
.request-target {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-2);
}
.request-method {
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
color: white;
background: var(--method-color);
}
.request-url {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
color: var(--text-1);
}
.env-divider {
margin: 24px 0;
border-top: 1px solid #35353a;
}
.env-form {
display: grid;
gap: 14px;
}
.field {
display: grid;
gap: 6px;
}
.field label {
font-size: 13px;
color: #9aa0aa;
}
.field input {
height: 42px;
padding: 0 12px;
border-radius: 10px;
border: 1px solid #35353a;
background: #27272c;
color: white;
}
.field input:focus {
outline: none;
border-color: #5a9cff;
}
.env-modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 20px 24px;
border-top: 1px solid #35353a;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
+371 -11
View File
@@ -1,4 +1,257 @@
(function () {
window.DoxaEnvironmentManager = (function () {
const KEY = 'DoxaApi.environments';
const ACTIVE = 'DoxaApi.activeEnvironment';
function load() {
const stored = localStorage.getItem(KEY);
if (stored) {
try { return JSON.parse(stored); } catch (e) { /* fall through to default below */ }
}
return [{ name: 'Local', url: window.location.origin }];
}
function save(v) { localStorage.setItem(KEY, JSON.stringify(v)); }
function active() { return localStorage.getItem(ACTIVE) || 'Local'; }
function currentUrl() { const env = load().find(x => x.name === active()); return env ? env.url : window.location.origin; }
window.getConfiguredBaseUrl = currentUrl;
function hideEnvironmentManager() {
const modal = document.getElementById('envManagerModal');
if (modal) modal.remove();
}
function show() {
const existing = document.getElementById('envManagerModal');
if (existing) existing.remove();
const envs = load();
const activeEnv = active();
const envCards = envs.map(env => `
<div class="env-card ${env.name === activeEnv ? 'active' : ''}"
data-env="${escapeHtml(env.name)}">
<div class="env-card-left">
<input
type="radio"
name="environment"
${env.name === activeEnv ? 'checked' : ''}
>
<div>
<div class="env-name">
${escapeHtml(env.name)}
${env.name === activeEnv
? '<span class="env-badge">ACTIVE</span>'
: ''}
</div>
<div class="env-url">
${escapeHtml(env.url)}
</div>
</div>
</div>
<div class="env-actions">
<button class="icon-btn danger delete-env"
data-env="${env.name}">
🗑
</button>
</div>
</div>
`).join('');
document.body.insertAdjacentHTML('beforeend', `
<div id="envManagerModal" class="env-overlay">
<div class="env-modal">
<div class="env-modal-header">
<div>
<h2>Environment Manager</h2>
<div class="env-subtitle">
Manage API environments and switch between them
</div>
</div>
<button class="env-close-btn"
id="closeEnvManager">
</button>
</div>
<div class="env-modal-body">
<div class="env-section-title">
Environments
</div>
<div class="env-list">
${envCards}
</div>
<div class="env-divider"></div>
<div class="env-section-title">
Add Environment
</div>
<div class="env-form">
<div class="field">
<label>Name</label>
<input id="newEnvName"
type="text"
placeholder="Development">
</div>
<div class="field">
<label>Base URL</label>
<input id="newEnvUrl"
type="url"
placeholder="https://api.example.com">
</div>
<button id="addEnvBtn"
class="tb-btn tb-btn-accent">
+ Add Environment
</button>
</div>
</div>
<div class="env-modal-footer">
<button id="cancelEnvManager"
class="tb-btn">
Close
</button>
</div>
</div>
</div>
`);
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
document.addEventListener('click', e => {
// Close button
if (
e.target.id === 'closeEnvManager' ||
e.target.id === 'cancelEnvManager'
) {
window.DoxaEnvironmentManager.hide();
return;
}
// Overlay click
if (
e.target.classList &&
e.target.classList.contains('env-overlay')
) {
window.DoxaEnvironmentManager.hide();
return;
}
// Select environment
const card = e.target.closest('.env-card');
if (card && !e.target.closest('.env-actions')) {
localStorage.setItem(ACTIVE, card.dataset.env);
window.DoxaEnvironmentManager.hide();
window.DoxaEnvironmentManager.show();
return;
}
// Delete environment
const deleteBtn = e.target.closest('.delete-env');
if (deleteBtn) {
const envName = deleteBtn.dataset.env;
if (envName === 'Local') {
alert('The Local environment cannot be deleted.');
return;
}
const envs = load().filter(x => x.name !== envName);
window.DoxaEnvironmentManager.save(envs);
if (active() === envName) {
localStorage.setItem(ACTIVE, 'Local');
}
window.DoxaEnvironmentManager.hide();
window.DoxaEnvironmentManager.show();
return;
}
// Add environment
if (e.target.id === 'addEnvBtn') {
const name = document
.getElementById('newEnvName')
.value
.trim();
const url = document
.getElementById('newEnvUrl')
.value
.trim();
if (!name || !url) {
alert('Please enter both a name and URL.');
return;
}
try {
new URL(url);
} catch {
alert('Please enter a valid URL.');
return;
}
const envs = load();
if (envs.some(x => x.name === name)) {
alert('An environment with that name already exists.');
return;
}
envs.push({
name,
url
});
save(envs);
hide();
show();
}
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
hide();
}
});
return {
show,
currentUrl,
active,
hide: hideEnvironmentManager
};
})();
(function () {
"use strict"; "use strict";
const SPEC_URL = window.__DOXA_API_SPEC_URL__ || "doxaApi.json"; const SPEC_URL = window.__DOXA_API_SPEC_URL__ || "doxaApi.json";
@@ -453,9 +706,63 @@
function renderTry(group, endpoint) { function renderTry(group, endpoint) {
const methodColorVar = `var(--m-${endpoint.method.toLowerCase()}, var(--m-default))`; const methodColorVar = `var(--m-${endpoint.method.toLowerCase()}, var(--m-default))`;
let html = `<div class="fade-in" style="--method-color:${methodColorVar}">`; let html = `<div class="fade-in" style="--method-color:${methodColorVar}">`;
html += `<div class="try-header"> const currentEnv = window.DoxaEnvironmentManager.active();
<span class="try-title"><span class="live-dot"></span>Try it</span> const currentBaseUrl = window.DoxaEnvironmentManager.currentUrl();
</div>`; const fullUrl =
currentBaseUrl.replace(/\/$/, '') +
endpoint.path;
html += `
<div class="try-header">
<div class="try-header-main">
<div class="try-title">
<span class="live-dot"></span>
Try it
</div>
<div class="try-subtitle">
Execute requests against the selected environment
</div>
</div>
</div>
<div class="try-env-bar">
<div class="try-env-info">
<div class="try-env-label">
Environment
</div>
<div class="try-env-main">
<span class="try-env-badge">
${escapeHtml(currentEnv)}
</span>
<span class="try-env-url">
${escapeHtml(currentBaseUrl)}
</span>
</div>
</div>
<button
id="changeEnvironmentBtn"
class="tb-btn tb-btn-sm"
type="button">
Change
</button>
</div>
<div class="request-target">
<span class="request-method method-${endpoint.method.toLowerCase()}">
${endpoint.method}
</span>
<code class="request-url">
${escapeHtml(fullUrl)}
</code>
</div>
`;
html += `<div class="try-tabs"> html += `<div class="try-tabs">
<button class="try-tab ${activeTryTab === "body" ? "active" : ""}" data-try-tab="body">Request</button> <button class="try-tab ${activeTryTab === "body" ? "active" : ""}" data-try-tab="body">Request</button>
@@ -573,6 +880,15 @@
bindCodeTabEvents(endpoint); bindCodeTabEvents(endpoint);
bindHistoryTabEvents(endpoint); bindHistoryTabEvents(endpoint);
const changeEnvironmentBtn =
document.getElementById('changeEnvironmentBtn');
if (changeEnvironmentBtn) {
changeEnvironmentBtn.addEventListener('click', () => {
window.DoxaEnvironmentManager.show();
});
}
} }
function renderCodeTab(endpoint) { function renderCodeTab(endpoint) {
@@ -875,6 +1191,10 @@
} }
} }
function getMockUrl() {
return new URL(window.location.href + "/mock");
}
async function sendTryRequest(endpoint) { async function sendTryRequest(endpoint) {
const btn = document.getElementById("sendBtn"); const btn = document.getElementById("sendBtn");
const label = document.getElementById("sendBtnLabel"); const label = document.getElementById("sendBtnLabel");
@@ -888,8 +1208,8 @@
const isMock = mockMode && mockServerAvailable(); const isMock = mockMode && mockServerAvailable();
const url = isMock const url = isMock
? new URL("mock", window.location.href) ? getMockUrl()
: new URL(path, window.location.origin); : new URL(path, (typeof window.getConfiguredBaseUrl === 'function') ? window.getConfiguredBaseUrl() : window.location.origin);
if (isMock) { if (isMock) {
url.searchParams.set("operationId", endpoint.operationId); url.searchParams.set("operationId", endpoint.operationId);
@@ -1478,6 +1798,46 @@
} }
} }
const envBtn = document.getElementById("envBtn");
if (envBtn) {
document.addEventListener("click", (e) => {
if (e.target.closest('#envBtn')) {
window.DoxaEnvironmentManager.show();
return;
}
if (
e.target.id === 'closeEnvManager' ||
e.target.id === 'cancelEnvManager' ||
e.target.classList.contains('env-overlay')
) {
window.DoxaEnvironmentManager.hide();
return;
}
const card = e.target.closest('.env-card');
if (card) {
const envName = card.dataset.env;
window.DoxaEnvironmentManager.setActive(envName);
document
.querySelectorAll('.env-card')
.forEach(c => c.classList.remove('active'));
card.classList.add('active');
document
.querySelectorAll('input[name="environment"]')
.forEach(r => r.checked = false);
card.querySelector('input[type="radio"]').checked = true;
}
});
} else {
envBtn.addEventListener("click", () => showEnvironmentManager());
}
const diffFile = document.getElementById("diffFile"); const diffFile = document.getElementById("diffFile");
const compareBtn = document.getElementById("compareBtn"); const compareBtn = document.getElementById("compareBtn");
compareBtn?.addEventListener("click", () => diffFile?.click()); compareBtn?.addEventListener("click", () => diffFile?.click());
@@ -1563,7 +1923,7 @@
el.search.addEventListener("input", () => renderNav(el.search.value)); el.search.addEventListener("input", () => renderNav(el.search.value));
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
if (e.key === "/" && document.activeElement !== el.search) { if (e.ctrlKey && e.key === "/" && document.activeElement !== el.search) {
e.preventDefault(); e.preventDefault();
el.search.focus(); el.search.focus();
} }
@@ -1878,7 +2238,7 @@
} }
} }
const request = RequestBuilder.buildRequest(endpoint, paramState, window.location.origin); const request = RequestBuilder.buildRequest(endpoint, paramState, (typeof window.getConfiguredBaseUrl === 'function') ? window.getConfiguredBaseUrl() : window.location.origin);
const code = CodeGenerator.generate(lang, request); const code = CodeGenerator.generate(lang, request);
document.getElementById('codeBlock').textContent = code; document.getElementById('codeBlock').textContent = code;
} }
@@ -1951,7 +2311,7 @@
*/ */
function initializeKeyboardShortcuts() { function initializeKeyboardShortcuts() {
const shortcuts = { const shortcuts = {
'/': () => document.getElementById('searchInput')?.focus(), 'Key/': (e) => e.ctrlKey && document.getElementById('searchInput')?.focus(),
'KeyS': (e) => e.ctrlKey && saveCurrentRequest(), 'KeyS': (e) => e.ctrlKey && saveCurrentRequest(),
'KeyK': (e) => e.ctrlKey && toggleCommandPalette(), 'KeyK': (e) => e.ctrlKey && toggleCommandPalette(),
'Tab': (e) => handleTabNavigation(e), 'Tab': (e) => handleTabNavigation(e),
@@ -1985,7 +2345,7 @@
if (searchInput && !searchInput.parentElement.querySelector('.kbd')) { if (searchInput && !searchInput.parentElement.querySelector('.kbd')) {
const kbd = document.createElement('kbd'); const kbd = document.createElement('kbd');
kbd.className = 'kbd search-kbd'; kbd.className = 'kbd search-kbd';
kbd.textContent = '/'; kbd.textContent = 'Ctrl + /';
searchInput.parentElement.appendChild(kbd); searchInput.parentElement.appendChild(kbd);
} }
} }
@@ -2121,7 +2481,7 @@
* Improved error logging * Improved error logging
*/ */
const originalConsoleError = console.error; const originalConsoleError = console.error;
console.error = function(...args) { console.error = function (...args) {
originalConsoleError.apply(console, args); originalConsoleError.apply(console, args);
const message = args[0]?.toString?.() || String(args[0]); const message = args[0]?.toString?.() || String(args[0]);
if (message && !message.includes('HTTP')) { if (message && !message.includes('HTTP')) {
+1 -1
View File
@@ -28,7 +28,7 @@ const CodeGenerator = (() => {
curl: (req) => generateCurl(req), curl: (req) => generateCurl(req),
fetch: (req) => generateFetch(req), fetch: (req) => generateFetch(req),
axios: (req) => generateAxios(req), axios: (req) => generateAxios(req),
httpclient: (req) => generateHttpClient(req), httpclient: (req) => generateCSharp(req),
httppy: (req) => generatePythonHttp(req), httppy: (req) => generatePythonHttp(req),
requests: (req) => generatePythonRequests(req), requests: (req) => generatePythonRequests(req),
httpgo: (req) => generateGoHttp(req), httpgo: (req) => generateGoHttp(req),
+22 -1
View File
@@ -28,7 +28,6 @@
<div class="search-wrap"> <div class="search-wrap">
<svg viewBox="0 0 20 20" fill="none" class="search-ico"><circle cx="9" cy="9" r="6" stroke="currentColor" stroke-width="1.8" /><path d="M17 17L13.5 13.5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg> <svg viewBox="0 0 20 20" fill="none" class="search-ico"><circle cx="9" cy="9" r="6" stroke="currentColor" stroke-width="1.8" /><path d="M17 17L13.5 13.5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
<input id="searchInput" type="text" placeholder="Search endpoints, methods, paths…" autocomplete="off" spellcheck="false" /> <input id="searchInput" type="text" placeholder="Search endpoints, methods, paths…" autocomplete="off" spellcheck="false" />
<kbd class="search-kbd">/</kbd>
</div> </div>
</div> </div>
<div class="topbar-right"> <div class="topbar-right">
@@ -41,6 +40,28 @@
<span>Auth</span> <span>Auth</span>
<span class="auth-dot" id="authBadgeDot" style="display:none"></span> <span class="auth-dot" id="authBadgeDot" style="display:none"></span>
</button> </button>
<button id="envBtn" class="tb-btn" title="Environments">
<svg viewBox="0 0 20 20" fill="none">
<rect x="2.5" y="3" width="5" height="4" rx="1"
stroke="currentColor" stroke-width="1.8" />
<rect x="12.5" y="3" width="5" height="4" rx="1"
stroke="currentColor" stroke-width="1.8" />
<rect x="7.5" y="13" width="5" height="4" rx="1"
stroke="currentColor" stroke-width="1.8" />
<path d="M5 7v2.5h10V7"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round" />
<path d="M10 9.5v3.5"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round" />
</svg>
<span>Environments</span>
</button>
<button id="consoleBtn" class="tb-btn" title="Console"> <button id="consoleBtn" class="tb-btn" title="Console">
<svg viewBox="0 0 20 20" fill="none"><rect x="2" y="3" width="16" height="14" rx="2" stroke="currentColor" stroke-width="1.8" /><path d="M6 8l4 4-4 4M12 16h4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" /></svg> <svg viewBox="0 0 20 20" fill="none"><rect x="2" y="3" width="16" height="14" rx="2" stroke="currentColor" stroke-width="1.8" /><path d="M6 8l4 4-4 4M12 16h4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" /></svg>
<span>Console</span> <span>Console</span>
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 197 KiB