feat: accordion layout for model show details modal
This commit is contained in:
+166
-4
@@ -302,12 +302,19 @@ class LLMMonitorApp {
|
|||||||
detailsModal.setAttribute("aria-hidden", "false");
|
detailsModal.setAttribute("aria-hidden", "false");
|
||||||
|
|
||||||
if (!showData) {
|
if (!showData) {
|
||||||
detailsContent.textContent = "Loading show details...";
|
detailsContent.innerHTML = `
|
||||||
|
<div class="flex items-center gap-2 text-gray-400 text-sm py-4">
|
||||||
|
<svg class="animate-spin w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
|
||||||
|
</svg>
|
||||||
|
Loading details…
|
||||||
|
</div>`;
|
||||||
this.loadModelShowDetails(modelName, detailsContent);
|
this.loadModelShowDetails(modelName, detailsContent);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
detailsContent.textContent = JSON.stringify(showData, null, 2);
|
detailsContent.innerHTML = this.buildAccordionHTML(showData);
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadModelShowDetails(modelName, detailsContent) {
|
async loadModelShowDetails(modelName, detailsContent) {
|
||||||
@@ -329,16 +336,171 @@ class LLMMonitorApp {
|
|||||||
this.lastData.models.showByModel[modelName] = showData;
|
this.lastData.models.showByModel[modelName] = showData;
|
||||||
|
|
||||||
if (this.selectedModelName === modelName) {
|
if (this.selectedModelName === modelName) {
|
||||||
detailsContent.textContent = JSON.stringify(showData, null, 2);
|
detailsContent.innerHTML = this.buildAccordionHTML(showData);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
if (this.selectedModelName === modelName) {
|
if (this.selectedModelName === modelName) {
|
||||||
detailsContent.textContent = "Show details are not available for this model.";
|
detailsContent.innerHTML = '<p class="text-gray-400 text-sm py-2">Show details are not available for this model.</p>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Accordion helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
buildAccordionHTML(showData) {
|
||||||
|
if (!showData || typeof showData !== "object") {
|
||||||
|
return '<p class="text-gray-400 text-sm py-2">No details available.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionOrder = ["details", "model_info", "parameters", "template", "modelfile", "license"];
|
||||||
|
const allKeys = Object.keys(showData);
|
||||||
|
const orderedKeys = [
|
||||||
|
...sectionOrder.filter(k => k in showData),
|
||||||
|
...allKeys.filter(k => !sectionOrder.includes(k))
|
||||||
|
];
|
||||||
|
|
||||||
|
let html = '<div class="space-y-2">';
|
||||||
|
|
||||||
|
orderedKeys.forEach((key, index) => {
|
||||||
|
const value = showData[key];
|
||||||
|
const isFirst = index === 0;
|
||||||
|
const contentId = `acc-${key.replace(/[^a-z0-9]/gi, "-")}`;
|
||||||
|
const label = this.formatAccordionLabel(key);
|
||||||
|
const body = this.renderAccordionBody(key, value);
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="border border-gray-700 rounded-lg overflow-hidden">
|
||||||
|
<button type="button"
|
||||||
|
class="accordion-header w-full flex items-center justify-between px-4 py-2.5 bg-gray-800 hover:bg-gray-700 text-left transition-colors duration-150"
|
||||||
|
onclick="app.toggleAccordion('${contentId}', this)"
|
||||||
|
aria-expanded="${isFirst}">
|
||||||
|
<span class="font-semibold text-sm text-gray-200">${label}</span>
|
||||||
|
<svg class="accordion-chevron w-4 h-4 text-gray-400 transition-transform duration-200 ${isFirst ? "rotate-180" : ""}"
|
||||||
|
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div id="${contentId}" class="accordion-content bg-gray-900 border-t border-gray-700 ${isFirst ? "" : "hidden"}">
|
||||||
|
<div class="px-4 py-3">${body}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += "</div>";
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatAccordionLabel(key) {
|
||||||
|
const labels = {
|
||||||
|
details: "Details",
|
||||||
|
model_info: "Model Info",
|
||||||
|
parameters: "Parameters",
|
||||||
|
template: "Template",
|
||||||
|
modelfile: "Modelfile",
|
||||||
|
license: "License"
|
||||||
|
};
|
||||||
|
const icons = {
|
||||||
|
details: "▦",
|
||||||
|
model_info: "🧠",
|
||||||
|
parameters: "⚙",
|
||||||
|
template: "📄",
|
||||||
|
modelfile: "📦",
|
||||||
|
license: "📜"
|
||||||
|
};
|
||||||
|
const icon = icons[key] || "▸";
|
||||||
|
const text = labels[key] || key.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
|
||||||
|
return `<span class="mr-2 text-base">${icon}</span>${text}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAccordionBody(key, value) {
|
||||||
|
if (key === "details" && value && typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
return this.renderDetailsGrid(value);
|
||||||
|
}
|
||||||
|
if (key === "model_info" && value && typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
return this.renderModelInfoTable(value);
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return `<pre class="text-xs text-gray-300 whitespace-pre-wrap font-mono leading-relaxed max-h-60 overflow-y-auto">${this.escapeHtml(value)}</pre>`;
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
return this.renderKeyValueList(value);
|
||||||
|
}
|
||||||
|
return `<span class="text-sm text-gray-300">${this.escapeHtml(String(value))}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderDetailsGrid(details) {
|
||||||
|
const labelMap = {
|
||||||
|
family: "Family",
|
||||||
|
families: "Families",
|
||||||
|
parameter_size: "Parameters",
|
||||||
|
quantization_level: "Quantization",
|
||||||
|
format: "Format",
|
||||||
|
parent_model: "Parent Model"
|
||||||
|
};
|
||||||
|
let html = '<div class="grid grid-cols-2 gap-x-6 gap-y-3">';
|
||||||
|
for (const [k, v] of Object.entries(details)) {
|
||||||
|
const label = labelMap[k] || k.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
|
||||||
|
const display = Array.isArray(v) ? v.join(", ") : String(v);
|
||||||
|
html += `
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-xs text-gray-500 uppercase tracking-wide">${this.escapeHtml(label)}</span>
|
||||||
|
<span class="text-sm text-gray-200 font-medium mt-0.5">${this.escapeHtml(display)}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
html += "</div>";
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderModelInfoTable(modelInfo) {
|
||||||
|
let html = '<dl class="space-y-1.5">';
|
||||||
|
for (const [k, v] of Object.entries(modelInfo)) {
|
||||||
|
const display = typeof v === "object" ? JSON.stringify(v) : String(v);
|
||||||
|
html += `
|
||||||
|
<div class="flex gap-3 text-xs">
|
||||||
|
<dt class="text-gray-500 font-mono shrink-0 w-5/12 truncate" title="${this.escapeHtml(k)}">${this.escapeHtml(k)}</dt>
|
||||||
|
<dd class="text-gray-300 break-all">${this.escapeHtml(display)}</dd>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
html += "</dl>";
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderKeyValueList(obj) {
|
||||||
|
let html = '<dl class="space-y-1.5">';
|
||||||
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
|
const display = typeof v === "object" ? JSON.stringify(v) : String(v);
|
||||||
|
html += `
|
||||||
|
<div class="flex gap-3 text-xs">
|
||||||
|
<dt class="text-gray-500 shrink-0 w-1/3">${this.escapeHtml(k)}</dt>
|
||||||
|
<dd class="text-gray-300 break-all">${this.escapeHtml(display)}</dd>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
html += "</dl>";
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
escapeHtml(str) {
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleAccordion(contentId, btn) {
|
||||||
|
const content = document.getElementById(contentId);
|
||||||
|
if (!content) return;
|
||||||
|
const isHidden = content.classList.contains("hidden");
|
||||||
|
content.classList.toggle("hidden", !isHidden);
|
||||||
|
const chevron = btn.querySelector(".accordion-chevron");
|
||||||
|
if (chevron) chevron.classList.toggle("rotate-180", isHidden);
|
||||||
|
btn.setAttribute("aria-expanded", String(isHidden));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fine accordion helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
hideModelDetails() {
|
hideModelDetails() {
|
||||||
const detailsModal = document.getElementById("model-details-modal");
|
const detailsModal = document.getElementById("model-details-modal");
|
||||||
const detailsDialog = document.getElementById("model-details-dialog");
|
const detailsDialog = document.getElementById("model-details-dialog");
|
||||||
|
|||||||
@@ -140,8 +140,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<button id="model-details-close" type="button" class="text-gray-300 hover:text-white text-2xl leading-none px-2" aria-label="Close modal">×</button>
|
<button id="model-details-close" type="button" class="text-gray-300 hover:text-white text-2xl leading-none px-2" aria-label="Close modal">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body overflow-y-auto max-h-[75vh]">
|
||||||
<pre id="model-details-content" class="bg-gray-900 text-gray-200 text-xs p-4 rounded-lg whitespace-pre-wrap"></pre>
|
<div id="model-details-content"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user