diff --git a/web/components.json b/web/components.json new file mode 100644 index 0000000..6dae454 --- /dev/null +++ b/web/components.json @@ -0,0 +1,5 @@ +[ + "components/api-client/api-client.js", + "components/side-nav/side-nav.js", + "components/date-display/date-display.js" +] diff --git a/web/components/api-client/api-client.js b/web/components/api-client/api-client.js new file mode 100644 index 0000000..d9eab75 --- /dev/null +++ b/web/components/api-client/api-client.js @@ -0,0 +1,123 @@ +// API service component. +// +// Declare once in the shell: +// +// +// 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) diff --git a/web/components/api-client/manifest.json b/web/components/api-client/manifest.json new file mode 100644 index 0000000..49c3c0c --- /dev/null +++ b/web/components/api-client/manifest.json @@ -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')." +} diff --git a/web/components/date-display/date-display.js b/web/components/date-display/date-display.js new file mode 100644 index 0000000..dab208e --- /dev/null +++ b/web/components/date-display/date-display.js @@ -0,0 +1,69 @@ +import { config } from '../../core/config.js' + +class DateDisplay extends HTMLElement { + #interval = null + + connectedCallback() { + this.attachShadow({ mode: 'open' }) + this.shadowRoot.innerHTML = ` + +
Current time
+
+
+
agent →
+ ` + + 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) diff --git a/web/components/date-display/manifest.json b/web/components/date-display/manifest.json new file mode 100644 index 0000000..6320a47 --- /dev/null +++ b/web/components/date-display/manifest.json @@ -0,0 +1,5 @@ +{ + "tag": "date-display", + "version": "0.1.0", + "description": "Displays current date and time, refreshed every second." +} diff --git a/web/components/side-nav/manifest.json b/web/components/side-nav/manifest.json new file mode 100644 index 0000000..67cdfbd --- /dev/null +++ b/web/components/side-nav/manifest.json @@ -0,0 +1,5 @@ +{ + "tag": "side-nav", + "version": "0.1.0", + "description": "Left sidebar navigation, driven by navigation.json." +} diff --git a/web/components/side-nav/side-nav.js b/web/components/side-nav/side-nav.js new file mode 100644 index 0000000..f8d7435 --- /dev/null +++ b/web/components/side-nav/side-nav.js @@ -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: ``, + vpc: ``, + subnet: ``, + vm: ``, +} + +function resolveIcon(name) { + return ICONS[name] ?? `` +} + +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 ` + + ${resolveIcon(item.icon)} + ${item.label} + + ` + }).join('') + + this.shadowRoot.innerHTML = ` + + + ${links} + ` + } +} + +customElements.define('side-nav', SideNav) diff --git a/web/config.json b/web/config.json new file mode 100644 index 0000000..bf6e218 --- /dev/null +++ b/web/config.json @@ -0,0 +1,5 @@ +{ + "netbox_url": "http://netbox.local", + "netbox_token": "your-token-here", + "agent_url": "http://127.0.0.1:8080" +} diff --git a/web/core/config.js b/web/core/config.js new file mode 100644 index 0000000..21618e4 --- /dev/null +++ b/web/core/config.js @@ -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() diff --git a/web/core/registry.js b/web/core/registry.js new file mode 100644 index 0000000..56671cc --- /dev/null +++ b/web/core/registry.js @@ -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}`) +} diff --git a/web/favicon.svg b/web/favicon.svg new file mode 100644 index 0000000..3b8d831 --- /dev/null +++ b/web/favicon.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..a9301aa --- /dev/null +++ b/web/index.html @@ -0,0 +1,100 @@ + + + + + + two — dashboard + + + + + +
+

two

+ network orchestrator +
+ + + +
+ + +
+
+ +
+
+ + + + + diff --git a/web/navigation.json b/web/navigation.json new file mode 100644 index 0000000..96a2b94 --- /dev/null +++ b/web/navigation.json @@ -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" + } +]