Initial commit from Martins Github
This commit is contained in:
337
app/ui/index.html
Normal file
337
app/ui/index.html
Normal file
@@ -0,0 +1,337 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>P5G Marvis</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--surface: #161b27;
|
||||
--card: #1e2535;
|
||||
--border: #2a3148;
|
||||
--text: #e2e8f0;
|
||||
--muted: #7a8499;
|
||||
--purple: #7c3aed;
|
||||
--purple-dim: rgba(124,58,237,0.15);
|
||||
--blue: #3b82f6;
|
||||
--green: #10b981;
|
||||
--yellow: #f59e0b;
|
||||
--red: #ef4444;
|
||||
--font: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { height: 100%; }
|
||||
body {
|
||||
background: var(--bg); color: var(--text);
|
||||
font-family: var(--font); font-size: 14px; height: 100%;
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────────── */
|
||||
header {
|
||||
background: var(--surface); border-bottom: 1px solid var(--border);
|
||||
padding: 0 20px; height: 52px;
|
||||
display: flex; align-items: center; gap: 12px; flex-shrink: 0;
|
||||
}
|
||||
.logo {
|
||||
width: 30px; height: 30px; border-radius: 8px; flex-shrink: 0;
|
||||
background: linear-gradient(135deg, var(--purple), var(--blue));
|
||||
display: flex; align-items: center; justify-content: center; font-size: 16px;
|
||||
}
|
||||
header h1 { font-size: 15px; font-weight: 700; letter-spacing: -0.01em; }
|
||||
header h1 span { color: var(--muted); font-weight: 400; }
|
||||
.pill {
|
||||
font-size: 10px; padding: 2px 8px; border-radius: 20px; font-weight: 600;
|
||||
background: var(--purple-dim); color: var(--purple); border: 1px solid var(--purple);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.conn { margin-left: auto; display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
|
||||
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); flex-shrink: 0; }
|
||||
.dot.err { background: var(--red); animation: none; }
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.35} }
|
||||
.dot { animation: pulse 2.5s infinite; }
|
||||
|
||||
/* ── Layout ─────────────────────────────────────────────────────── */
|
||||
.layout {
|
||||
display: grid; grid-template-columns: 320px 1fr; flex: 1; overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Left panel ─────────────────────────────────────────────────── */
|
||||
.left {
|
||||
background: var(--surface); border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
.section { padding: 14px 16px; border-bottom: 1px solid var(--border); }
|
||||
.section-title {
|
||||
font-size: 10px; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: .1em; color: var(--muted); margin-bottom: 12px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.refresh-btn {
|
||||
background: none; border: none; color: var(--muted); cursor: pointer;
|
||||
font-size: 13px; padding: 1px 4px; border-radius: 4px; transition: color .15s;
|
||||
}
|
||||
.refresh-btn:hover { color: var(--text); }
|
||||
|
||||
/* NF grid */
|
||||
.nf-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 7px; }
|
||||
.nf-card {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 9px 6px; text-align: center; border-left: 3px solid var(--border);
|
||||
transition: border-color .2s;
|
||||
}
|
||||
.nf-card.up { border-left-color: var(--green); }
|
||||
.nf-card.down { border-left-color: var(--red); }
|
||||
.nf-name { font-size: 11px; font-weight: 700; color: var(--muted); }
|
||||
.nf-state { font-size: 9px; margin-top: 3px; text-transform: uppercase; letter-spacing:.05em; }
|
||||
.nf-card.up .nf-state { color: var(--green); }
|
||||
.nf-card.down .nf-state { color: var(--red); }
|
||||
|
||||
/* Alerts panel */
|
||||
.alerts-scroll { flex: 1; overflow-y: auto; padding: 14px 16px; }
|
||||
.alerts-scroll::-webkit-scrollbar { width: 4px; }
|
||||
.alerts-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||
.no-alerts { text-align: center; padding: 24px 0; color: var(--muted); font-size: 13px; }
|
||||
.ok-icon { font-size: 26px; margin-bottom: 6px; }
|
||||
.alert-row {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 9px 12px; margin-bottom: 7px; border-left: 3px solid var(--yellow);
|
||||
}
|
||||
.alert-row.critical { border-left-color: var(--red); }
|
||||
.alert-row-name { font-size: 12px; font-weight: 600; }
|
||||
.alert-row-desc { font-size: 11px; color: var(--muted); margin-top: 2px; }
|
||||
|
||||
/* ── Chat panel ─────────────────────────────────────────────────── */
|
||||
.chat { display: flex; flex-direction: column; overflow: hidden; }
|
||||
.messages {
|
||||
flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 14px;
|
||||
}
|
||||
.messages::-webkit-scrollbar { width: 4px; }
|
||||
.messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||
|
||||
.msg { display: flex; gap: 10px; max-width: 84%; }
|
||||
.msg.user { align-self: flex-end; flex-direction: row-reverse; }
|
||||
.avatar {
|
||||
width: 30px; height: 30px; border-radius: 50%; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 13px;
|
||||
}
|
||||
.msg.ai .avatar { background: linear-gradient(135deg,var(--purple),var(--blue)); }
|
||||
.msg.user .avatar { background: var(--card); border: 1px solid var(--border); }
|
||||
.bubble {
|
||||
background: var(--card); border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 10px 14px; font-size: 13.5px; line-height: 1.55;
|
||||
}
|
||||
.msg.user .bubble { background: var(--purple); border-color: var(--purple); }
|
||||
.bubble strong { color: var(--text); }
|
||||
.bubble code { background: rgba(255,255,255,.08); padding: 1px 5px; border-radius: 4px; font-size: 12px; }
|
||||
.ts { font-size: 10px; color: var(--muted); margin-top: 4px; }
|
||||
.msg.user .ts { text-align: right; }
|
||||
|
||||
/* Typing indicator */
|
||||
.typing { display: flex; gap: 5px; padding: 6px 2px; align-items: center; }
|
||||
.typing b { width: 7px; height: 7px; background: var(--muted); border-radius: 50%; animation: bounce 1.2s infinite; }
|
||||
.typing b:nth-child(2){animation-delay:.2s}.typing b:nth-child(3){animation-delay:.4s}
|
||||
@keyframes bounce{0%,100%{transform:translateY(0)}50%{transform:translateY(-7px)}}
|
||||
|
||||
/* Suggestions */
|
||||
.chips { display: flex; gap: 6px; padding: 0 20px 10px; overflow-x: auto; flex-shrink: 0; }
|
||||
.chips::-webkit-scrollbar { display: none; }
|
||||
.chip {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 20px;
|
||||
color: var(--text); padding: 5px 13px; font-size: 12px; cursor: pointer;
|
||||
white-space: nowrap; transition: border-color .15s, background .15s;
|
||||
}
|
||||
.chip:hover { border-color: var(--purple); background: var(--purple-dim); }
|
||||
|
||||
/* Input bar */
|
||||
.input-bar {
|
||||
padding: 14px 20px; border-top: 1px solid var(--border);
|
||||
background: var(--surface); display: flex; gap: 10px; flex-shrink: 0; align-items: center;
|
||||
}
|
||||
.msg-input {
|
||||
flex: 1; background: var(--card); border: 1px solid var(--border);
|
||||
border-radius: 24px; color: var(--text); padding: 9px 18px;
|
||||
font-size: 13.5px; font-family: var(--font); outline: none; transition: border-color .15s;
|
||||
}
|
||||
.msg-input:focus { border-color: var(--purple); }
|
||||
.msg-input::placeholder { color: var(--muted); }
|
||||
.send {
|
||||
background: linear-gradient(135deg,var(--purple),var(--blue)); border: none;
|
||||
border-radius: 50%; width: 40px; height: 40px; color: #fff; font-size: 15px;
|
||||
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||
transition: opacity .15s; flex-shrink: 0;
|
||||
}
|
||||
.send:hover { opacity: .85; }
|
||||
.send:disabled { opacity: .35; cursor: default; }
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
.left { max-height: 260px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="logo">✦</div>
|
||||
<h1>P5G Marvis <span>/ Network AI</span></h1>
|
||||
<div class="pill">AI</div>
|
||||
<div class="conn"><div class="dot" id="dot"></div><span id="connLabel">Connecting…</span></div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<!-- Left panel -->
|
||||
<div class="left">
|
||||
<div class="section">
|
||||
<div class="section-title">
|
||||
Network Functions
|
||||
<button class="refresh-btn" onclick="refresh()" title="Refresh">↻</button>
|
||||
</div>
|
||||
<div class="nf-grid" id="nfGrid">
|
||||
<div class="nf-card"><div class="nf-name">···</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alerts-scroll">
|
||||
<div class="section-title" style="margin-bottom:10px">Active Alerts</div>
|
||||
<div id="alertsContent"><div style="color:var(--muted);font-size:12px">Loading…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat panel -->
|
||||
<div class="chat">
|
||||
<div class="messages" id="messages"></div>
|
||||
<div class="chips">
|
||||
<button class="chip" onclick="ask(this)">Network health overview</button>
|
||||
<button class="chip" onclick="ask(this)">Any active alerts?</button>
|
||||
<button class="chip" onclick="ask(this)">UPF status</button>
|
||||
<button class="chip" onclick="ask(this)">SMF session analysis</button>
|
||||
<button class="chip" onclick="ask(this)">Subscriber issues?</button>
|
||||
<button class="chip" onclick="ask(this)">What can you do?</button>
|
||||
</div>
|
||||
<div class="input-bar">
|
||||
<input class="msg-input" id="inp" placeholder="Ask about your P5G network…"
|
||||
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();send()}">
|
||||
<button class="send" id="sendBtn" onclick="send()">▶</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── Utilities ──────────────────────────────────────────────────────────────
|
||||
const $ = id => document.getElementById(id);
|
||||
const ts = () => new Date().toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});
|
||||
|
||||
function md(text) {
|
||||
// minimal markdown: **bold**, `code`, newlines
|
||||
return text
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
function addMsg(role, html, isTyping=false) {
|
||||
const wrap = $('messages');
|
||||
const el = document.createElement('div');
|
||||
el.className = `msg ${role}`;
|
||||
if (isTyping) el.id = 'typing';
|
||||
const av = role==='ai' ? '✦' : '👤';
|
||||
el.innerHTML = `
|
||||
<div class="avatar">${av}</div>
|
||||
<div>
|
||||
<div class="bubble">${isTyping ? '<div class="typing"><b></b><b></b><b></b></div>' : html}</div>
|
||||
${isTyping ? '' : `<div class="ts">${ts()}</div>`}
|
||||
</div>`;
|
||||
wrap.appendChild(el);
|
||||
wrap.scrollTop = wrap.scrollHeight;
|
||||
return el;
|
||||
}
|
||||
|
||||
// ── Network status ─────────────────────────────────────────────────────────
|
||||
async function loadNFs() {
|
||||
try {
|
||||
const d = await (await fetch('./api/network/status')).json();
|
||||
const grid = $('nfGrid');
|
||||
grid.innerHTML = '';
|
||||
(d.nfs||[]).forEach(nf => {
|
||||
const c = document.createElement('div');
|
||||
c.className = `nf-card ${nf.state}`;
|
||||
c.title = nf.instance || nf.name;
|
||||
c.innerHTML = `<div class="nf-name">${nf.name}</div>
|
||||
<div class="nf-state">${nf.state==='up'?'● up':nf.state==='down'?'● dn':'○ n/a'}</div>`;
|
||||
grid.appendChild(c);
|
||||
});
|
||||
$('dot').className = 'dot';
|
||||
$('connLabel').textContent = 'Live';
|
||||
} catch {
|
||||
$('dot').className = 'dot err';
|
||||
$('connLabel').textContent = 'Unreachable';
|
||||
$('nfGrid').innerHTML = '<div style="color:var(--muted);font-size:12px;grid-column:1/-1">Cannot reach API</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAlerts() {
|
||||
try {
|
||||
const d = await (await fetch('./api/alerts')).json();
|
||||
const el = $('alertsContent');
|
||||
if (!d.alerts || d.alerts.length === 0) {
|
||||
el.innerHTML = '<div class="no-alerts"><div class="ok-icon">✓</div>No active alerts</div>';
|
||||
} else {
|
||||
el.innerHTML = d.alerts.slice(0,10).map(a =>
|
||||
`<div class="alert-row ${a.severity||'warning'}">
|
||||
<div class="alert-row-name">${a.name}</div>
|
||||
<div class="alert-row-desc">${a.summary||a.instance||''}</div>
|
||||
</div>`
|
||||
).join('');
|
||||
}
|
||||
} catch {
|
||||
$('alertsContent').innerHTML = '<div style="color:var(--muted);font-size:12px">Cannot reach alerts API</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() { await Promise.all([loadNFs(), loadAlerts()]); }
|
||||
|
||||
// ── Chat ──────────────────────────────────────────────────────────────────
|
||||
async function send() {
|
||||
const inp = $('inp');
|
||||
const text = inp.value.trim();
|
||||
if (!text) return;
|
||||
inp.value = '';
|
||||
$('sendBtn').disabled = true;
|
||||
addMsg('user', md(text));
|
||||
addMsg('ai', '', true);
|
||||
|
||||
try {
|
||||
const res = await fetch('./api/query', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({query: text})
|
||||
});
|
||||
const data = await res.json();
|
||||
document.getElementById('typing')?.remove();
|
||||
addMsg('ai', md(data.response || 'No response.'));
|
||||
} catch {
|
||||
document.getElementById('typing')?.remove();
|
||||
addMsg('ai', '⚠️ Cannot reach the Marvis API. Is the service running?');
|
||||
}
|
||||
|
||||
$('sendBtn').disabled = false;
|
||||
inp.focus();
|
||||
}
|
||||
|
||||
function ask(btn) { $('inp').value = btn.textContent; send(); }
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────
|
||||
(async () => {
|
||||
addMsg('ai', md(
|
||||
"Hello! I'm **P5G Marvis** — your AI assistant for HPE Private 5G.\n\n" +
|
||||
"I monitor your network functions in real time, surface active alerts, and answer " +
|
||||
"natural language questions about your network.\n\n" +
|
||||
"_Loading network state…_"
|
||||
));
|
||||
await refresh();
|
||||
setInterval(refresh, 30000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user