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"
+ }
+]