web: start: premier j'ai d'un dashboard modulaire

Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
GnomeZworc 2026-05-29 23:36:59 +02:00
commit 14ef0ecc83
Signed by: nicolas.boufideline
GPG key ID: 4406BBBF8845D632
13 changed files with 479 additions and 0 deletions

View file

@ -0,0 +1,123 @@
// API service component.
//
// Declare once in the shell:
// <api-client></api-client>
//
// Use from any other component:
// const api = document.querySelector('api-client')
// const vrfs = await api.netbox.list('/ipam/vrfs/')
// const vpc = await api.agent.get('/vpcs/vp-admin')
// await api.agent.post('/vpcs', { name: 'vp-admin', cidr: '10.0.0.0/8' })
// await api.agent.delete('/vpcs/vp-admin')
// await api.agent.waitFor('/vms/i-test1', 'started')
//
// Phase 2: swap this component for one that points to the orchestrator.
// No other component changes.
import { config } from '../../core/config.js'
export class ApiError extends Error {
constructor(origin, status, message) {
super(`[${origin}] HTTP ${status}: ${message}`)
this.origin = origin // 'netbox' | 'agent'
this.status = status
}
}
class ApiClient extends HTMLElement {
connectedCallback() {
this.style.display = 'none'
this.netbox = this.#buildNetbox()
this.agent = this.#buildAgent()
}
// ── Internal ──────────────────────────────────────────────────────────────
async #request(origin, url, options = {}) {
let response
try {
response = await fetch(url, options)
} catch (e) {
throw new ApiError(origin, 0, `Network error: ${e.message}`)
}
if (response.status === 204) return null
const text = await response.text()
let json
try { json = JSON.parse(text) } catch { json = null }
if (!response.ok) {
const detail = json?.detail ?? json?.message ?? text
throw new ApiError(origin, response.status, detail)
}
return json
}
// ── Netbox ────────────────────────────────────────────────────────────────
#buildNetbox() {
const headers = () => ({
'Authorization': `Token ${config.netbox_token}`,
'Accept': 'application/json',
})
const url = (path, params = {}) => {
const u = new URL(path, config.netbox_url + '/api/')
Object.entries(params).forEach(([k, v]) => {
if (v != null) u.searchParams.set(k, v)
})
return u.toString()
}
return {
// Returns results array. Netbox paginates; limit=1000 covers most cases.
list: (path, params = {}) =>
this.#request('netbox', url(path, { limit: 1000, ...params }), { headers: headers() })
.then(d => d?.results ?? []),
get: (path, params = {}) =>
this.#request('netbox', url(path, params), { headers: headers() }),
}
}
// ── Agent ─────────────────────────────────────────────────────────────────
#buildAgent() {
const headers = (body = false) => ({
'Accept': 'application/json',
...(body ? { 'Content-Type': 'application/json' } : {}),
})
const url = path => new URL(path, config.agent_url + '/').toString()
const req = (method, path, body) =>
this.#request('agent', url(path), {
method,
headers: headers(body != null),
...(body != null ? { body: JSON.stringify(body) } : {}),
})
return {
get: path => req('GET', path),
list: path => req('GET', path),
post: (path, b) => req('POST', path, b),
delete: path => req('DELETE', path),
// Poll path until resource.state === desired or timeout.
waitFor: async (path, desired, { timeout = 120_000, interval = 2_000 } = {}) => {
const deadline = Date.now() + timeout
while (Date.now() < deadline) {
const r = await req('GET', path)
if (r?.state === desired) return r
if (r?.state === 'error') throw new ApiError('agent', 'error', `${path} reached error state`)
await new Promise(ok => setTimeout(ok, interval))
}
throw new ApiError('agent', 'timeout', `${path} did not reach '${desired}' in ${timeout}ms`)
},
}
}
}
customElements.define('api-client', ApiClient)

View file

@ -0,0 +1,5 @@
{
"tag": "api-client",
"version": "0.1.0",
"description": "Service component. Provides Netbox and agent API access to all other components via document.querySelector('api-client')."
}

View file

@ -0,0 +1,69 @@
import { config } from '../../core/config.js'
class DateDisplay extends HTMLElement {
#interval = null
connectedCallback() {
this.attachShadow({ mode: 'open' })
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
background: #1e1e2e;
border: 1px solid #313244;
border-radius: 8px;
padding: 16px 20px;
font-family: monospace;
color: #cdd6f4;
min-width: 260px;
}
.label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #6c7086;
margin-bottom: 6px;
}
.time {
font-size: 28px;
font-weight: 600;
color: #89b4fa;
}
.date {
font-size: 13px;
color: #a6adc8;
margin-top: 4px;
}
.agent {
margin-top: 12px;
font-size: 11px;
color: #585b70;
border-top: 1px solid #313244;
padding-top: 10px;
}
</style>
<div class="label">Current time</div>
<div class="time" id="time"></div>
<div class="date" id="date"></div>
<div class="agent">agent <span id="agent-url"></span></div>
`
this.shadowRoot.getElementById('agent-url').textContent = config.agent_url
this.#tick()
this.#interval = setInterval(() => this.#tick(), 1000)
}
disconnectedCallback() {
clearInterval(this.#interval)
}
#tick() {
const now = new Date()
this.shadowRoot.getElementById('time').textContent = now.toLocaleTimeString()
this.shadowRoot.getElementById('date').textContent = now.toLocaleDateString(undefined, {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
})
}
}
customElements.define('date-display', DateDisplay)

View file

@ -0,0 +1,5 @@
{
"tag": "date-display",
"version": "0.1.0",
"description": "Displays current date and time, refreshed every second."
}

View file

@ -0,0 +1,5 @@
{
"tag": "side-nav",
"version": "0.1.0",
"description": "Left sidebar navigation, driven by navigation.json."
}

View file

@ -0,0 +1,100 @@
// Side navigation component.
// Reads items from navigation.json (path relative to web root via data-base attribute,
// defaults to ./navigation.json).
// Highlights the active entry by matching href against window.location.pathname.
const ICONS = {
dashboard: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>`,
vpc: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5"/><path d="M3 12c0 1.66 4.03 3 9 3s9-1.34 9-3"/></svg>`,
subnet: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="20" rx="2"/><path d="M8 12h8M12 8v8"/></svg>`,
vm: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>`,
}
function resolveIcon(name) {
return ICONS[name] ?? `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/></svg>`
}
class SideNav extends HTMLElement {
async connectedCallback() {
this.attachShadow({ mode: 'open' })
const navPath = this.dataset.src ?? './navigation.json'
const response = await fetch(navPath)
if (!response.ok) throw new Error(`side-nav: failed to load ${navPath}`)
const items = await response.json()
this.#render(items)
}
#render(items) {
const currentPage = window.location.pathname.split('/').pop() || 'index.html'
const links = items.map(item => {
const isActive = item.href === currentPage
return `
<a href="${item.href}" class="nav-item ${isActive ? 'active' : ''}">
<span class="icon">${resolveIcon(item.icon)}</span>
<span class="label">${item.label}</span>
</a>
`
}).join('')
this.shadowRoot.innerHTML = `
<style>
:host {
display: flex;
flex-direction: column;
width: 220px;
min-width: 220px;
background: #1e1e2e;
border-right: 1px solid #313244;
padding: 16px 12px;
gap: 4px;
height: 100%;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-radius: 6px;
text-decoration: none;
color: #a6adc8;
font-size: 14px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
transition: background 0.1s, color 0.1s;
}
.nav-item:hover {
background: #313244;
color: #cdd6f4;
}
.nav-item.active {
background: #313244;
color: #89b4fa;
}
.nav-item.active .icon {
color: #89b4fa;
}
.icon {
display: flex;
align-items: center;
color: #6c7086;
flex-shrink: 0;
}
.nav-item:hover .icon {
color: #cdd6f4;
}
</style>
${links}
`
}
}
customElements.define('side-nav', SideNav)