web: start: premier j'ai d'un dashboard modulaire
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
parent
437766d812
commit
cf8661ff7e
13 changed files with 479 additions and 0 deletions
5
web/components.json
Normal file
5
web/components.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
[
|
||||||
|
"components/api-client/api-client.js",
|
||||||
|
"components/side-nav/side-nav.js",
|
||||||
|
"components/date-display/date-display.js"
|
||||||
|
]
|
||||||
123
web/components/api-client/api-client.js
Normal file
123
web/components/api-client/api-client.js
Normal 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)
|
||||||
5
web/components/api-client/manifest.json
Normal file
5
web/components/api-client/manifest.json
Normal 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')."
|
||||||
|
}
|
||||||
69
web/components/date-display/date-display.js
Normal file
69
web/components/date-display/date-display.js
Normal 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)
|
||||||
5
web/components/date-display/manifest.json
Normal file
5
web/components/date-display/manifest.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"tag": "date-display",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Displays current date and time, refreshed every second."
|
||||||
|
}
|
||||||
5
web/components/side-nav/manifest.json
Normal file
5
web/components/side-nav/manifest.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"tag": "side-nav",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Left sidebar navigation, driven by navigation.json."
|
||||||
|
}
|
||||||
100
web/components/side-nav/side-nav.js
Normal file
100
web/components/side-nav/side-nav.js
Normal 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)
|
||||||
5
web/config.json
Normal file
5
web/config.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"netbox_url": "http://netbox.local",
|
||||||
|
"netbox_token": "your-token-here",
|
||||||
|
"agent_url": "http://127.0.0.1:8080"
|
||||||
|
}
|
||||||
6
web/core/config.js
Normal file
6
web/core/config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
// Single source of truth for configuration.
|
||||||
|
// Phase 2: replace the fetch with a call to the orchestrator.
|
||||||
|
const response = await fetch('./config.json')
|
||||||
|
if (!response.ok) throw new Error('Failed to load config.json')
|
||||||
|
|
||||||
|
export const config = await response.json()
|
||||||
10
web/core/registry.js
Normal file
10
web/core/registry.js
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
// Loads and registers all components listed in components.json.
|
||||||
|
// To add a component: git clone into components/, add the path here.
|
||||||
|
const response = await fetch('./components.json')
|
||||||
|
if (!response.ok) throw new Error('Failed to load components.json')
|
||||||
|
|
||||||
|
const components = await response.json()
|
||||||
|
|
||||||
|
for (const path of components) {
|
||||||
|
await import(`../${path}`)
|
||||||
|
}
|
||||||
24
web/favicon.svg
Normal file
24
web/favicon.svg
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<!-- Background -->
|
||||||
|
<rect width="32" height="32" rx="6" fill="#1e1e2e"/>
|
||||||
|
|
||||||
|
<!-- Server rack unit 1 -->
|
||||||
|
<rect x="5" y="7" width="22" height="5" rx="1.5" fill="#313244"/>
|
||||||
|
<circle cx="23" cy="9.5" r="1.2" fill="#a6e3a1"/>
|
||||||
|
<rect x="8" y="9" width="10" height="1" rx="0.5" fill="#585b70"/>
|
||||||
|
|
||||||
|
<!-- Server rack unit 2 -->
|
||||||
|
<rect x="5" y="14" width="22" height="5" rx="1.5" fill="#313244"/>
|
||||||
|
<circle cx="23" cy="16.5" r="1.2" fill="#89b4fa"/>
|
||||||
|
<rect x="8" y="16" width="10" height="1" rx="0.5" fill="#585b70"/>
|
||||||
|
|
||||||
|
<!-- Server rack unit 3 -->
|
||||||
|
<rect x="5" y="21" width="22" height="5" rx="1.5" fill="#313244"/>
|
||||||
|
<circle cx="23" cy="23.5" r="1.2" fill="#89b4fa"/>
|
||||||
|
<rect x="8" y="23" width="6" height="1" rx="0.5" fill="#585b70"/>
|
||||||
|
|
||||||
|
<!-- Rack frame left -->
|
||||||
|
<rect x="3" y="5" width="2" height="23" rx="1" fill="#45475a"/>
|
||||||
|
<!-- Rack frame right -->
|
||||||
|
<rect x="27" y="5" width="2" height="23" rx="1" fill="#45475a"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 996 B |
100
web/index.html
Normal file
100
web/index.html
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>two — dashboard</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="./favicon.svg">
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: #181825;
|
||||||
|
color: #cdd6f4;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 24px;
|
||||||
|
border-bottom: 1px solid #313244;
|
||||||
|
background: #1e1e2e;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #89b4fa;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
header span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6c7086;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 28px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-content: flex-start;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#error {
|
||||||
|
display: none;
|
||||||
|
background: #313244;
|
||||||
|
border: 1px solid #f38ba8;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
color: #f38ba8;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>two</h1>
|
||||||
|
<span>network orchestrator</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<api-client></api-client>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
<side-nav></side-nav>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div id="error"></div>
|
||||||
|
<date-display></date-display>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import './core/registry.js'
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', e => {
|
||||||
|
const el = document.getElementById('error')
|
||||||
|
el.style.display = 'block'
|
||||||
|
el.textContent = `Error: ${e.reason?.message ?? e.reason}`
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
22
web/navigation.json
Normal file
22
web/navigation.json
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"label": "Dashboard",
|
||||||
|
"href": "index.html",
|
||||||
|
"icon": "dashboard"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "VPCs",
|
||||||
|
"href": "vpc.html",
|
||||||
|
"icon": "vpc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Subnets",
|
||||||
|
"href": "subnet.html",
|
||||||
|
"icon": "subnet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "VMs",
|
||||||
|
"href": "vm.html",
|
||||||
|
"icon": "vm"
|
||||||
|
}
|
||||||
|
]
|
||||||
Loading…
Add table
Add a link
Reference in a new issue