Add enterprise appliance experience Phase 1 foundation
This commit is contained in:
1 parent
b3eec34158
commit
ebaa2e7219
69 files changed
+6832
-18
No files matched your search
@@ -292,12 +292,13 @@ async function api(path, opts={}) {
|
||||
function renderReadiness(d) {
|
||||
readinessData = d;
|
||||
const score = d.readiness?.score ?? 0;
|
||||
const status = d.readiness?.status || 'unknown';
|
||||
const status = d.state || d.readiness?.status || 'unavailable';
|
||||
const entitlement = d.entitlement || d.license?.entitlement || {};
|
||||
document.getElementById('readiness-score').textContent = score + '%';
|
||||
const statusEl = document.getElementById('readiness-status');
|
||||
statusEl.textContent = status.replace(/_/g, ' ');
|
||||
statusEl.className = 'pill ' + (status === 'ready' ? 'ok' : status === 'limited' ? 'warn' : 'bad');
|
||||
if (window.CezenExperience) window.CezenExperience.renderState(statusEl, {state: status, severity: d.severity, summary: status.replace(/_/g, ' ')});
|
||||
else statusEl.textContent = status.replace(/_/g, ' ');
|
||||
statusEl.title = window.CezenExperience ? window.CezenExperience.formatFreshness(d.observed_at) : '';
|
||||
document.getElementById('license-tier').textContent = d.license?.label || '—';
|
||||
document.getElementById('license-position').textContent = `${d.license?.positioning || '—'} · status: ${licenseStatusLabel(entitlement.license_status || d.license?.license_status || 'missing')}`;
|
||||
|
||||
@@ -457,6 +458,7 @@ function exportReadiness() {
|
||||
loadAll();
|
||||
</script>
|
||||
<script src="auth.js"></script>
|
||||
<script src="experience.js?v=1"></script>
|
||||
<script src="branding.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -50,6 +50,22 @@
|
||||
max: 'Server Max',
|
||||
};
|
||||
|
||||
var PRODUCT_VOCABULARY = Object.freeze({
|
||||
companyAttribution: 'Powered by Cezen',
|
||||
workstation: 'Nexus One AI Workstation',
|
||||
server: 'Nexus One AI Server',
|
||||
tiers: Object.freeze({ starter: 'Server S', basic: 'Server M', pro: 'Server L', max: 'Server Max' }),
|
||||
capabilityReasons: Object.freeze({
|
||||
licensed: 'Included in the current license',
|
||||
not_licensed: 'Not included in the current license',
|
||||
not_provisioned: 'Licensed but not installed on this appliance',
|
||||
unsupported_hardware: 'Not supported by the detected hardware',
|
||||
degraded: 'Temporarily degraded',
|
||||
unavailable: 'Currently unavailable'
|
||||
})
|
||||
});
|
||||
window.NexusOneVocabulary = PRODUCT_VOCABULARY;
|
||||
|
||||
var SERVICE_PORTS = {
|
||||
'3001': { name: 'open-webui', label: 'Open WebUI' },
|
||||
'11434': { name: 'ollama', label: 'Ollama' },
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/** Shared, dependency-free customer experience primitives for Nexus One AI. */
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var STATES = ['loading', 'empty', 'ready', 'degraded', 'failed', 'restricted', 'unavailable', 'stale'];
|
||||
var SEVERITIES = ['info', 'warning', 'critical'];
|
||||
|
||||
function canonicalState(value) {
|
||||
value = String(value || '').toLowerCase().replace(/[_ ]+/g, '-');
|
||||
var aliases = { ok: 'ready', healthy: 'ready', limited: 'degraded', warning: 'degraded', error: 'failed', critical: 'failed', not_ready: 'failed' };
|
||||
value = aliases[value] || value;
|
||||
return STATES.indexOf(value) >= 0 ? value : 'unavailable';
|
||||
}
|
||||
|
||||
function announce(message, priority) {
|
||||
var id = priority === 'assertive' ? 'cezen-live-assertive' : 'cezen-live-polite';
|
||||
var region = document.getElementById(id);
|
||||
if (!region) {
|
||||
region = document.createElement('div');
|
||||
region.id = id;
|
||||
region.className = 'sr-only';
|
||||
region.setAttribute('aria-live', priority === 'assertive' ? 'assertive' : 'polite');
|
||||
region.setAttribute('aria-atomic', 'true');
|
||||
document.body.appendChild(region);
|
||||
}
|
||||
region.textContent = '';
|
||||
global.setTimeout(function () { region.textContent = String(message || ''); }, 20);
|
||||
}
|
||||
|
||||
function renderState(element, payload) {
|
||||
if (!element) return;
|
||||
payload = payload || {};
|
||||
var state = canonicalState(payload.state || payload.status);
|
||||
var severity = SEVERITIES.indexOf(payload.severity) >= 0 ? payload.severity : (state === 'failed' ? 'critical' : state === 'degraded' || state === 'stale' ? 'warning' : 'info');
|
||||
element.classList.remove.apply(element.classList, STATES.map(function (item) { return 'cx-state-' + item; }));
|
||||
element.classList.add('cx-state', 'cx-state-' + state);
|
||||
element.setAttribute('data-state', state);
|
||||
element.setAttribute('data-severity', severity);
|
||||
element.setAttribute('role', severity === 'critical' ? 'alert' : 'status');
|
||||
element.textContent = payload.summary || state.replace(/-/g, ' ');
|
||||
}
|
||||
|
||||
function formatFreshness(observedAt, now) {
|
||||
if (!observedAt) return 'Freshness unknown';
|
||||
var observed = new Date(observedAt).getTime();
|
||||
var current = now ? new Date(now).getTime() : Date.now();
|
||||
if (!isFinite(observed)) return 'Freshness unknown';
|
||||
var seconds = Math.max(0, Math.round((current - observed) / 1000));
|
||||
if (seconds < 60) return 'Observed ' + seconds + ' seconds ago';
|
||||
if (seconds < 3600) return 'Observed ' + Math.floor(seconds / 60) + ' minutes ago';
|
||||
return 'Observed ' + Math.floor(seconds / 3600) + ' hours ago';
|
||||
}
|
||||
|
||||
function focusFirstError(container) {
|
||||
var target = (container || document).querySelector('[aria-invalid="true"], .field-error, .cx-error');
|
||||
if (!target) return false;
|
||||
if (!target.hasAttribute('tabindex')) target.setAttribute('tabindex', '-1');
|
||||
target.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
global.CezenExperience = Object.freeze({
|
||||
states: STATES.slice(),
|
||||
canonicalState: canonicalState,
|
||||
announce: announce,
|
||||
renderState: renderState,
|
||||
formatFreshness: formatFreshness,
|
||||
focusFirstError: focusFirstError
|
||||
});
|
||||
})(window);
|
||||
@@ -245,6 +245,11 @@ function renderSummary(status) {
|
||||
const dot = document.getElementById('hc-summary-dot');
|
||||
const title = document.getElementById('hc-summary-title');
|
||||
const sub = document.getElementById('hc-summary-sub');
|
||||
if (window.CezenExperience) {
|
||||
const mapped = status === 'ok' ? 'ready' : status === 'warning' ? 'degraded' : status === 'critical' ? 'failed' : 'unavailable';
|
||||
title.className = 'hc-summary-title cx-state cx-state-' + mapped;
|
||||
title.setAttribute('role', mapped === 'failed' ? 'alert' : 'status');
|
||||
}
|
||||
if (status === 'ok') {
|
||||
dot.className = 'hc-summary-dot healthy';
|
||||
title.textContent = 'Healthy — all systems normal';
|
||||
@@ -284,6 +289,7 @@ loadHealth();
|
||||
setInterval(loadHealth, 30000);
|
||||
</script>
|
||||
<script src="auth.js"></script>
|
||||
<script src="experience.js?v=1"></script>
|
||||
<script src="branding.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -817,11 +817,32 @@ async function fetchSystemInfo() {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchApplianceReadiness() {
|
||||
try {
|
||||
const response = await fetch('/api/system/readiness-report', { credentials: 'include' });
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
const label = document.getElementById('status-overall-label');
|
||||
const checked = document.getElementById('status-last-checked');
|
||||
if (label) {
|
||||
label.textContent = data.customer_impact || (data.state === 'ready' ? 'Appliance ready' : 'Appliance needs attention');
|
||||
label.setAttribute('role', data.severity === 'critical' ? 'alert' : 'status');
|
||||
}
|
||||
if (checked && window.CezenExperience) checked.textContent = window.CezenExperience.formatFreshness(data.observed_at);
|
||||
if (data.next_actions && data.next_actions.length && window.CezenExperience) window.CezenExperience.announce(label.textContent, data.severity === 'critical' ? 'assertive' : 'polite');
|
||||
} catch (e) {
|
||||
/* Existing service checks remain visible when readiness aggregation is unavailable. */
|
||||
}
|
||||
}
|
||||
|
||||
fetchSystemInfo();
|
||||
fetchMetrics();
|
||||
fetchApplianceReadiness();
|
||||
setInterval(fetchMetrics, 30000);
|
||||
setInterval(fetchApplianceReadiness, 30000);
|
||||
</script>
|
||||
<script src="auth.js"></script>
|
||||
<script src="experience.js?v=1"></script>
|
||||
<script src="branding.js?v=3"></script>
|
||||
<script>
|
||||
function renderLocalIcons() {
|
||||
|
||||
@@ -640,6 +640,7 @@ checkStepCompletion();
|
||||
</script>
|
||||
|
||||
<script src="auth.js"></script>
|
||||
<script src="experience.js?v=1"></script>
|
||||
<script src="branding.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -159,6 +159,26 @@ a { color: inherit; text-decoration: none; }
|
||||
font-size: .6875rem; font-weight: 600; letter-spacing: .02em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Shared enterprise state and accessibility primitives. */
|
||||
:where(a, button, input, select, textarea, [tabindex]):focus-visible {
|
||||
outline: 3px solid var(--brand);
|
||||
outline-offset: 2px;
|
||||
box-shadow: var(--shadow-focus);
|
||||
}
|
||||
.cx-state { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--border); border-radius: var(--radius-pill); padding: 3px 9px; font-weight: 600; }
|
||||
.cx-state::before { content: ''; width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
|
||||
.cx-state-ready { color: var(--green); background: var(--green-lt); border-color: var(--green-border); }
|
||||
.cx-state-degraded, .cx-state-stale { color: var(--amber); background: var(--amber-lt); border-color: var(--amber-border); }
|
||||
.cx-state-failed { color: var(--red); background: var(--red-lt); border-color: var(--red-border); }
|
||||
.cx-state-loading, .cx-state-empty, .cx-state-restricted, .cx-state-unavailable { color: var(--text-secondary); background: var(--surface2); }
|
||||
.cx-alert { border: 1px solid var(--border); border-radius: var(--radius-md); padding: 12px 14px; }
|
||||
.cx-alert[data-severity="critical"] { border-color: var(--red-border); background: var(--red-lt); }
|
||||
.cx-alert[data-severity="warning"] { border-color: var(--amber-border); background: var(--amber-lt); }
|
||||
.cx-error { color: var(--red); font-weight: 600; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
|
||||
}
|
||||
.badge-pill.violet { background: var(--brand-lt); color: var(--brand-text); border: 1px solid var(--brand-border); }
|
||||
.badge-pill.green { background: var(--green-lt); color: var(--green); border: 1px solid var(--green-border); }
|
||||
.badge-pill.amber { background: var(--amber-lt); color: var(--amber); border: 1px solid var(--amber-border); }
|
||||
|
||||
@@ -369,6 +369,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ts-issue" data-tags="setup interrupted resume recovery install">
|
||||
<div class="ts-issue-header" onclick="toggleIssue(this)"><div class="ts-issue-title">Setup or installation was interrupted</div><span class="ts-chevron">›</span></div>
|
||||
<div class="ts-issue-body"><p>Return to the setup page. Nexus One AI preserves a local operation record and shows whether setup can resume, should be retried, or needs support.</p><ol><li>Do not delete <code>/opt/cezen/setup-operation.json</code>.</li><li>Review <code>/var/log/cezen-install.log</code> for the last completed phase.</li><li>Use the displayed retry or recovery action. If it remains in <strong>Needs attention</strong>, contact support with the operation/correlation ID.</li></ol></div>
|
||||
</div>
|
||||
|
||||
<div class="ts-issue" data-tags="readiness stale degraded unavailable offline">
|
||||
<div class="ts-issue-header" onclick="toggleIssue(this)"><div class="ts-issue-title">Readiness is degraded, unavailable, or stale</div><span class="ts-chevron">›</span></div>
|
||||
<div class="ts-issue-body"><p>Open <a href="health.html">Health Center</a> and review affected capabilities and observation time. Stale means the last observation is too old to claim current health; it does not automatically mean the appliance has failed.</p><p>This guidance is stored locally and does not require public internet access.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
@@ -418,6 +427,7 @@ document.querySelector('.ts-issue-header').click();
|
||||
</script>
|
||||
|
||||
<script src="auth.js"></script>
|
||||
<script src="experience.js?v=1"></script>
|
||||
<script src="branding.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user