Files
core/internal/api/ui_failures.tmpl

526 lines
25 KiB
Cheetah

{{define "failures"}}
<!DOCTYPE html>
<html lang="en">
{{template "head" .}}
<body>
{{template "topbar" .}}
{{template "breadcrumbs" .}}
<style>
.failures-table td {
vertical-align: top;
overflow-wrap: anywhere;
word-break: break-word;
white-space: normal;
}
.failures-details-cell a {
display: block;
max-height: 7.2em;
overflow: auto;
line-height: 1.2;
}
</style>
<main class="container">
<section class="card">
<div class="button-row" style="justify-content: space-between; margin-bottom: 12px;">
<h2 style="margin:0;">Active Failures</h2>
<button class="button" type="button" id="open-manual-failure-modal">Register Failure</button>
</div>
<div class="meta" id="manual-failure-page-message" style="margin-bottom:10px;"></div>
{{if .ActiveFailures}}
<table class="table failures-table" data-disable-auto-filters="true" style="table-layout:fixed; width:100%;">
<colgroup>
<col style="width: 22%;" />
<col style="width: 24%;" />
<col style="width: 18%;" />
<col style="width: 14%;" />
<col style="width: 22%;" />
</colgroup>
<thead>
<tr>
<th>Failure Time</th>
<th>Details</th>
<th>Server</th>
<th>Slot</th>
<th>Component</th>
</tr>
</thead>
<tbody>
{{range .ActiveFailures}}
<tr>
<td>
<a href="/ui/failure/{{.FailureID}}" class="js-local-failure-time" data-utc="{{formatTimeRFC3339 .FailureTime}}" title="{{formatTimeFull .FailureTime}}">
{{formatTime .FailureTime}}
</a><br />
<span class="meta">{{.FailureType}}</span>
</td>
<td class="failures-details-cell">
<a href="/ui/failure/{{.FailureID}}">{{if .Description}}{{.Description}}{{else}}—{{end}}</a>
</td>
<td>
<div><span class="badge {{assetStatusClass .AssetStatus}}">{{assetStatusText .AssetStatus}}</span></div>
<div style="margin-top:6px;">{{if .AssetID}}{{if .AssetURL}}<a href="{{.AssetURL}}">{{.AssetLabel}}</a>{{else}}{{.AssetLabel}}{{end}}{{else}}—{{end}}</div>
<div class="meta">{{if .AssetSerial}}{{.AssetSerial}}{{else}}—{{end}}</div>
</td>
<td>{{if .Slot}}{{.Slot}}{{else}}—{{end}}</td>
<td>
<div><span class="badge {{componentStatusClass .ComponentStatus}}">{{componentStatusText .ComponentStatus}}</span></div>
<div style="margin-top:6px;">{{if .ComponentURL}}<a href="{{.ComponentURL}}">{{.ComponentLabel}}</a>{{else}}{{.ComponentLabel}}{{end}}</div>
<div class="meta">{{if .ComponentSerial}}{{.ComponentSerial}}{{else}}—{{end}}</div>
</td>
</tr>
{{end}}
</tbody>
</table>
{{template "pagination" .ActivePager}}
{{else}}
<div class="meta">No active failures.</div>
{{end}}
</section>
<section class="card">
<h2 style="margin-top:0;">Failure Chronology</h2>
<div class="meta" style="margin-bottom:10px;">Includes failure registrations and repairs by replacing a component in the same slot on the same server.</div>
{{if .FailureChronologyGroups}}
{{range .FailureChronologyGroups}}
<div class="timeline-day">
<div class="timeline-day-title">{{.Day}}</div>
{{range .Items}}
<div class="timeline-card {{if eq .Kind "failure"}}timeline-card-accent-status-failed{{else}}timeline-card-accent-install{{end}}" style="cursor: default; margin-bottom:10px;">
<div class="timeline-card-header">
<div class="timeline-card-title">
{{if eq .Kind "failure"}}Failure{{else}}Replacement repair{{end}}
</div>
<div class="timeline-card-time">{{formatTimeFull .EventTime}}</div>
</div>
<div class="timeline-card-badges">
{{if eq .Kind "failure"}}
<span class="timeline-card-badge timeline-card-badge-status-failed">{{if .FailureType}}{{.FailureType}}{{else}}component_failed{{end}}</span>
{{else}}
<span class="timeline-card-badge timeline-card-badge-install">component_installed</span>
{{end}}
</div>
<div class="timeline-card-context" style="margin-top:8px;">
Server {{if .AssetURL}}<a href="{{.AssetURL}}">{{.AssetLabel}}</a>{{else}}{{.AssetLabel}}{{end}}{{if .AssetSerial}} ({{.AssetSerial}}){{end}} · Slot {{if .Slot}}{{.Slot}}{{else}}—{{end}}
</div>
{{if eq .Kind "failure"}}
<div class="timeline-card-subline" style="margin-top:6px;">
Component {{if .ComponentURL}}<a href="{{.ComponentURL}}">{{.ComponentLabel}}</a>{{else}}{{.ComponentLabel}}{{end}}{{if .ComponentSerial}} ({{.ComponentSerial}}){{end}}
{{if .Description}} · {{.Description}}{{end}}
</div>
{{else}}
<div class="timeline-card-subline" style="margin-top:6px;">
Replaced failed {{if .ComponentURL}}<a href="{{.ComponentURL}}">{{.ComponentLabel}}</a>{{else}}{{.ComponentLabel}}{{end}}
with {{if .ReplacementComponentURL}}<a href="{{.ReplacementComponentURL}}">{{.ReplacementComponentLabel}}</a>{{else}}{{.ReplacementComponentLabel}}{{end}}
{{if .ReplacementComponentSerial}} ({{.ReplacementComponentSerial}}){{end}}
</div>
{{end}}
</div>
{{end}}
</div>
{{end}}
{{else}}
<div class="meta">No failure chronology yet.</div>
{{end}}
{{template "pagination" .ChronologyPager}}
</section>
</main>
<datalist id="manual-failure-component-serials">
{{range .ManualFailureForm.ComponentOptions}}<option value="{{.Serial}}" label="{{if .Model}}{{.Serial}} - {{.Model}}{{else}}{{.Serial}}{{end}}"></option>{{end}}
</datalist>
<datalist id="manual-failure-server-serials">
{{range .ManualFailureForm.ServerSerials}}<option value="{{.}}"></option>{{end}}
</datalist>
<datalist id="manual-failure-locations">
{{range .ManualFailureForm.LocationSuggestions}}<option value="{{.}}"></option>{{end}}
</datalist>
<datalist id="manual-failure-dates">
{{range .ManualFailureForm.DateSuggestions}}<option value="{{.}}"></option>{{end}}
</datalist>
<datalist id="manual-failure-descriptions">
{{range .ManualFailureForm.DescriptionHints}}<option value="{{.}}"></option>{{end}}
</datalist>
<div class="modal" id="manual-failure-modal">
<div class="modal-card" style="width:min(720px,100%);">
<div class="button-row" style="justify-content:space-between; align-items:center;">
<h3 class="modal-header" style="margin:0;">Register Failure</h3>
<button class="button button-secondary" type="button" data-close-modal="manual-failure-modal">Close</button>
</div>
<div class="meta" id="manual-failure-modal-message" style="margin-bottom:10px;"></div>
<div class="form">
<div class="field">
<label for="manual-failure-server-serial">Server Serial</label>
<input class="input" id="manual-failure-server-serial" list="manual-failure-server-serials" />
</div>
<div class="field">
<label for="manual-failure-location">Location</label>
<input class="input" id="manual-failure-location" list="manual-failure-locations" placeholder="AOC#1" />
</div>
<div class="field">
<label for="manual-failure-component-serial">Component Serial</label>
<input class="input" id="manual-failure-component-serial" list="manual-failure-component-serials" />
</div>
<div class="field">
<label for="manual-failure-date">Failure Date</label>
<input class="input" id="manual-failure-date" list="manual-failure-dates" value="{{.ManualFailureForm.Today}}" />
</div>
<div class="field">
<label for="manual-failure-description">Description</label>
<input class="input" id="manual-failure-description" list="manual-failure-descriptions" />
</div>
<div id="manual-failure-location-candidates"></div>
<div class="meta" id="manual-failure-resolved-meta"></div>
<div class="button-row" style="margin-top:10px;">
<button class="button" type="button" id="manual-failure-review-btn" disabled>Review</button>
</div>
</div>
</div>
</div>
<div class="modal" id="manual-failure-confirm-modal">
<div class="modal-card" style="width:min(720px,100%);">
<div class="button-row" style="justify-content:space-between; align-items:center;">
<h3 class="modal-header" style="margin:0;">Confirm Failure Registration</h3>
<button class="button button-secondary" type="button" data-close-modal="manual-failure-confirm-modal">Close</button>
</div>
<div class="meta" id="manual-failure-confirm-message" style="margin-bottom:10px;"></div>
<div class="card" style="padding:12px;">
<div id="manual-failure-confirm-summary" class="meta"></div>
</div>
<div class="button-row" style="margin-top:12px;">
<button class="button button-secondary" type="button" id="manual-failure-confirm-back">Back</button>
<button class="button" type="button" id="manual-failure-submit-btn">Confirm & Register</button>
</div>
</div>
</div>
<script>
(() => {
const lookupByComponent = {{.ManualFailureForm.LookupByComponent}};
const state = { resolved: null, payload: null };
const pageMsg = document.getElementById('manual-failure-page-message');
const modalMsg = document.getElementById('manual-failure-modal-message');
const resolvedMeta = document.getElementById('manual-failure-resolved-meta');
const locationCandidates = document.getElementById('manual-failure-location-candidates');
const confirmMsg = document.getElementById('manual-failure-confirm-message');
const confirmSummary = document.getElementById('manual-failure-confirm-summary');
const reviewBtn = document.getElementById('manual-failure-review-btn');
const submitBtn = document.getElementById('manual-failure-submit-btn');
const componentInput = document.getElementById('manual-failure-component-serial');
const serverInput = document.getElementById('manual-failure-server-serial');
const locationInput = document.getElementById('manual-failure-location');
const locationDatalist = document.getElementById('manual-failure-locations');
const dateInput = document.getElementById('manual-failure-date');
const descInput = document.getElementById('manual-failure-description');
const allLookupEntries = Object.values(lookupByComponent || {});
function setText(el, txt) { if (el) el.textContent = txt || ''; }
function esc(v) {
return String(v ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function openModal(id) { const m = document.getElementById(id); if (m) m.classList.add('open'); }
function closeModal(id) { const m = document.getElementById(id); if (m) m.classList.remove('open'); }
document.querySelectorAll('[data-close-modal]').forEach((btn) => {
btn.addEventListener('click', () => closeModal(btn.getAttribute('data-close-modal')));
});
document.querySelectorAll('.modal').forEach((m) => {
m.addEventListener('click', (e) => { if (e.target === m) m.classList.remove('open'); });
});
function clearModalState() {
state.resolved = null;
state.payload = null;
setText(modalMsg, '');
setText(confirmMsg, '');
if (locationCandidates) locationCandidates.innerHTML = '';
if (resolvedMeta) resolvedMeta.innerHTML = '';
if (confirmSummary) confirmSummary.innerHTML = '';
if (reviewBtn) reviewBtn.disabled = true;
}
function renderLocationCandidates(items, message) {
if (!locationCandidates) return;
if (!items || !items.length) {
locationCandidates.innerHTML = '';
return;
}
const rows = items.map((it, idx) => {
const component = `${it.component_label || it.component_serial || it.component_id || ''}${it.component_model ? ' · ' + it.component_model : ''}`;
const server = `${it.server_label || ''}${it.server_serial ? ' (' + it.server_serial + ')' : ''}`;
return `
<button type="button" class="button button-secondary js-manual-failure-location-candidate" data-idx="${idx}" style="justify-content:flex-start; text-align:left; width:100%; margin-top:6px;">
${esc(component)} · ${esc(it.slot || '')} · ${esc(server)}
</button>
`;
}).join('');
locationCandidates.innerHTML = `
<div class="meta" style="margin-top:8px;">${esc(message || 'Select component for location')}</div>
<div style="margin-top:4px;">${rows}</div>
`;
locationCandidates.querySelectorAll('.js-manual-failure-location-candidate').forEach((btn) => {
btn.addEventListener('click', () => {
const idx = Number(btn.getAttribute('data-idx'));
const picked = items[idx];
if (!picked) return;
applyResolvedEntry(picked, 'location');
if (locationInput && picked.slot) locationInput.value = picked.slot;
renderResolvedMeta();
});
});
}
function refreshLocationDatalist() {
if (!locationDatalist) return;
const serverSerial = (serverInput?.value || '').trim().toLowerCase();
const componentSerial = (componentInput?.value || '').trim().toLowerCase();
let pool = allLookupEntries.slice();
if (serverSerial) {
pool = pool.filter((it) => String(it.server_serial || '').toLowerCase() === serverSerial);
}
if (componentSerial) {
const exact = pool.find((it) => String(it.component_serial || '').toLowerCase() === componentSerial);
if (exact && exact.slot) {
pool = [exact];
}
}
const seen = new Set();
const values = [];
pool.forEach((it) => {
const v = String(it.slot || '').trim();
if (!v) return;
const k = v.toLowerCase();
if (seen.has(k)) return;
seen.add(k);
values.push(v);
});
values.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
locationDatalist.innerHTML = values.map((v) => `<option value="${esc(v)}"></option>`).join('');
}
function applyResolvedEntry(resolved, origin) {
clearModalState();
if (!resolved) return;
state.resolved = resolved;
if (origin !== 'serial' && componentInput && resolved.component_serial) componentInput.value = resolved.component_serial;
if (origin !== 'server' && serverInput && (!serverInput.value || !serverInput.value.trim()) && resolved.server_serial) {
serverInput.value = resolved.server_serial;
}
if (origin !== 'location' && locationInput && (!locationInput.value || !locationInput.value.trim()) && resolved.slot) {
locationInput.value = resolved.slot;
}
if (origin === 'serial' && locationInput && resolved.slot) locationInput.value = resolved.slot;
if (origin === 'location' && componentInput && resolved.component_serial) componentInput.value = resolved.component_serial;
}
function renderResolvedMeta() {
const resolved = state.resolved;
const serverSerial = (serverInput?.value || '').trim();
const location = (locationInput?.value || '').trim();
if (!resolved) {
if (!(componentInput?.value || '').trim()) {
setText(resolvedMeta, 'Component serial or location is required.');
}
renderLocationCandidates([]);
if (reviewBtn) reviewBtn.disabled = true;
return;
}
const parts = [
`Component: ${resolved.component_label || resolved.component_serial || resolved.component_id}${resolved.component_model ? ' · ' + resolved.component_model : ''}`,
resolved.server_label || resolved.server_serial ? `Server: ${(resolved.server_label || '')}${resolved.server_serial ? ' (' + resolved.server_serial + ')' : ''}` : 'Server: —',
`Location: ${resolved.slot || ''}`
];
if (serverSerial && resolved.server_serial && serverSerial !== resolved.server_serial) {
parts.push('Warning: entered server serial differs from recognized current server');
}
if (location && resolved.slot && location !== resolved.slot) {
parts.push('Warning: entered location differs from recognized location');
}
if (resolvedMeta) resolvedMeta.innerHTML = parts.map((p) => `<div>${esc(p)}</div>`).join('');
renderLocationCandidates([]);
if (reviewBtn) reviewBtn.disabled = false;
}
function resolveBySerial() {
const componentSerial = (componentInput?.value || '').trim();
if (!componentSerial) {
clearModalState();
renderResolvedMeta();
return;
}
const resolved = lookupByComponent[String(componentSerial).toLowerCase()];
if (!resolved) {
clearModalState();
setText(resolvedMeta, 'Component serial not recognized. Select an existing component serial.');
return;
}
applyResolvedEntry(resolved, 'serial');
refreshLocationDatalist();
renderResolvedMeta();
}
function resolveByLocation() {
const location = (locationInput?.value || '').trim();
if (!location) {
if ((componentInput?.value || '').trim()) {
resolveBySerial();
} else {
clearModalState();
refreshLocationDatalist();
renderResolvedMeta();
}
return;
}
const serverSerial = (serverInput?.value || '').trim().toLowerCase();
const locNeedle = location.toLowerCase();
let candidates = allLookupEntries.filter((it) => String(it.slot || '').toLowerCase() === locNeedle);
if (serverSerial) {
const byServer = candidates.filter((it) => String(it.server_serial || '').toLowerCase() === serverSerial);
if (byServer.length === 1) {
applyResolvedEntry(byServer[0], 'location');
refreshLocationDatalist();
renderResolvedMeta();
return;
}
if (byServer.length > 1) {
clearModalState();
setText(resolvedMeta, 'Multiple components found for this location on the selected server. Select one below.');
renderLocationCandidates(byServer, 'Select component for this location/server');
refreshLocationDatalist();
return;
}
}
if (candidates.length === 1) {
applyResolvedEntry(candidates[0], 'location');
refreshLocationDatalist();
renderResolvedMeta();
return;
}
clearModalState();
if (candidates.length === 0) {
setText(resolvedMeta, 'Location not recognized. Enter component serial or server serial + location.');
} else {
setText(resolvedMeta, 'Location is ambiguous. Select a component below or enter server serial / component serial.');
renderLocationCandidates(candidates, 'Multiple components use this location');
}
refreshLocationDatalist();
}
function openRegisterModal() {
clearModalState();
if (dateInput && !String(dateInput.value || '').trim()) {
dateInput.value = '{{.ManualFailureForm.Today}}';
}
refreshLocationDatalist();
openModal('manual-failure-modal');
componentInput?.focus();
}
function buildPayload() {
const componentSerial = (componentInput?.value || '').trim();
const serverSerial = (serverInput?.value || '').trim();
const location = (locationInput?.value || '').trim();
const failureDate = (dateInput?.value || '').trim();
const description = (descInput?.value || '').trim();
if (!componentSerial) return { error: 'Component serial is required.' };
if (!state.resolved) return { error: 'Component serial must be recognized from suggestions.' };
if (!failureDate) return { error: 'Failure date is required.' };
if (!/^\d{4}-\d{2}-\d{2}$/.test(failureDate)) return { error: 'Failure date must be YYYY-MM-DD.' };
return {
payload: {
component_serial: componentSerial,
server_serial: serverSerial || null,
failure_date: failureDate,
description: description || null
}
};
}
function openConfirm() {
setText(modalMsg, '');
setText(confirmMsg, '');
const built = buildPayload();
if (built.error) {
setText(modalMsg, built.error);
return;
}
state.payload = built.payload;
const r = state.resolved || {};
const enteredLocation = (locationInput?.value || '').trim();
const lines = [
`<div><strong>Entered component serial:</strong> ${esc(built.payload.component_serial)}</div>`,
`<div><strong>Entered server serial:</strong> ${esc(built.payload.server_serial || '')}</div>`,
`<div><strong>Entered location:</strong> ${esc(enteredLocation || '')}</div>`,
`<div><strong>Failure date:</strong> ${esc(built.payload.failure_date)}</div>`,
`<div><strong>Description:</strong> ${esc(built.payload.description || '')}</div>`,
`<div style="margin-top:8px;"><strong>Recognized component:</strong> ${esc(r.component_label || r.component_serial || r.component_id || '')}${r.component_model ? ' · ' + esc(r.component_model) : ''}</div>`,
`<div><strong>Recognized server:</strong> ${esc(r.server_label || '')}${r.server_serial ? ' (' + esc(r.server_serial) + ')' : ''}</div>`,
`<div><strong>Recognized location:</strong> ${esc(r.slot || '')}</div>`
];
if (confirmSummary) confirmSummary.innerHTML = lines.join('');
openModal('manual-failure-confirm-modal');
}
async function submitFailure() {
if (!state.payload) {
setText(confirmMsg, 'Nothing to submit.');
return;
}
setText(confirmMsg, '');
if (submitBtn) submitBtn.disabled = true;
try {
const resp = await fetch('/failures', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(state.payload)
});
const body = await resp.json().catch(() => ({}));
if (!resp.ok) {
setText(confirmMsg, body.error || `Registration failed (${resp.status})`);
return;
}
closeModal('manual-failure-confirm-modal');
closeModal('manual-failure-modal');
setText(pageMsg, 'Failure registered.');
window.location.reload();
} catch (err) {
setText(confirmMsg, err?.message || 'Registration failed');
} finally {
if (submitBtn) submitBtn.disabled = false;
}
}
document.getElementById('open-manual-failure-modal')?.addEventListener('click', openRegisterModal);
componentInput?.addEventListener('input', resolveBySerial);
componentInput?.addEventListener('change', resolveBySerial);
locationInput?.addEventListener('input', resolveByLocation);
locationInput?.addEventListener('change', resolveByLocation);
serverInput?.addEventListener('input', refreshLocationDatalist);
serverInput?.addEventListener('change', () => {
if ((locationInput?.value || '').trim()) resolveByLocation();
else if ((componentInput?.value || '').trim()) resolveBySerial();
else refreshLocationDatalist();
});
document.getElementById('manual-failure-review-btn')?.addEventListener('click', openConfirm);
document.getElementById('manual-failure-confirm-back')?.addEventListener('click', () => closeModal('manual-failure-confirm-modal'));
document.getElementById('manual-failure-submit-btn')?.addEventListener('click', () => { submitFailure(); });
refreshLocationDatalist();
document.querySelectorAll('.js-local-failure-time').forEach((el) => {
const raw = el.getAttribute('data-utc');
if (!raw) return;
const dt = new Date(raw);
if (Number.isNaN(dt.getTime())) return;
el.textContent = dt.toLocaleDateString() + ' ' + dt.toLocaleTimeString();
});
})();
</script>
</body>
</html>
{{end}}