Skip to content

Commit 4253c0e

Browse files
committed
feat(homecore-ui): wire nav router + States / Services / Settings pages
Before: clicking Dashboard / States / Services / Settings highlighted the active nav button but the page content never changed. AppShell dispatched `hc-navigate` events but no listener acted on them. After (~232 LOC across 4 files): - main.ts (+20 LOC) tiny router: NAV_TO_TAG maps nav id → page custom element; on `hc-navigate`, swap the AppShell's child. - pages/States.ts (~86 LOC) HA-style entity table with 5 s refresh. - pages/Services.ts (~82 LOC) domain-grouped service registry, friendly empty state when no services registered. - pages/Settings.ts (~90 LOC) backend config readout + bearer-token editor (localStorage["homecore.token"]). Browser-verified all 4 nav clicks swap content; 0 console errors. Dashboard → 10 entity cards; States → 10-row table; Services → empty state (0 domains); Settings → config + token editor. Co-Authored-By: claude-flow <ruv@ruv.net>
1 parent 858a3d9 commit 4253c0e

4 files changed

Lines changed: 291 additions & 5 deletions

File tree

frontend/src/main.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,33 @@ import './styles/base.css';
1010
import './components/AppShell.js';
1111
import './components/StateCard.js';
1212
import './pages/Dashboard.js';
13+
import './pages/States.js';
14+
import './pages/Services.js';
15+
import './pages/Settings.js';
16+
17+
// Tiny router: the AppShell dispatches `hc-navigate` on every nav
18+
// click. We swap whichever page element is sitting in its <slot>
19+
// based on the new active id. Default page on first paint = dashboard.
20+
const NAV_TO_TAG: Record<string, string> = {
21+
dashboard: 'hc-dashboard',
22+
states: 'hc-states',
23+
services: 'hc-services',
24+
settings: 'hc-settings',
25+
};
26+
27+
function mountPage(shell: Element, tag: string): void {
28+
// Remove any existing page (everything that isn't itself the shell).
29+
Array.from(shell.children).forEach((c) => c.remove());
30+
shell.appendChild(document.createElement(tag));
31+
}
1332

14-
// Mount the Dashboard inside the AppShell's slot so the empty `<main>`
15-
// layout actually shows something on first paint.
1633
window.addEventListener('DOMContentLoaded', () => {
1734
const shell = document.querySelector('hc-app-shell');
18-
if (shell && !shell.querySelector('hc-dashboard')) {
19-
shell.appendChild(document.createElement('hc-dashboard'));
20-
}
35+
if (!shell) return;
36+
mountPage(shell, 'hc-dashboard');
37+
shell.addEventListener('hc-navigate', (ev) => {
38+
const id = (ev as CustomEvent<{ id: string }>).detail?.id;
39+
const tag = id ? NAV_TO_TAG[id] : undefined;
40+
if (tag) mountPage(shell, tag);
41+
});
2142
});

frontend/src/pages/Services.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* Services page — lists every registered service grouped by domain.
3+
* Reads from `/api/services` (HA-wire-compat).
4+
*/
5+
6+
import { LitElement, html, css } from 'lit';
7+
import { customElement, state } from 'lit/decorators.js';
8+
9+
import { HomecoreClient } from '../api/client.js';
10+
import type { ServiceDomainView } from '../api/types.js';
11+
12+
function resolveToken(): string {
13+
if (typeof localStorage !== 'undefined') {
14+
const stored = localStorage.getItem('homecore.token');
15+
if (stored) return stored;
16+
}
17+
const qs = new URL(window.location.href).searchParams.get('token');
18+
return qs ?? 'dev-token';
19+
}
20+
21+
@customElement('hc-services')
22+
export class ServicesPage extends LitElement {
23+
static styles = css`
24+
:host { display: block; padding: 24px; color: var(--hc-text, #e6eaee); font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif); }
25+
h1 { font-size: 18px; font-weight: 600; margin: 0 0 16px 0; }
26+
.domain { background: hsl(220 20% 10%); border: 1px solid var(--hc-border, #2a323e); border-radius: 8px; margin-bottom: 12px; padding: 14px 16px; }
27+
.domain h2 { font-size: 14px; font-weight: 600; margin: 0 0 8px 0; color: var(--hc-primary, #19d4e5); font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); }
28+
ul { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 6px; }
29+
li { background: hsl(220 25% 14%); padding: 4px 10px; border-radius: 4px; font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); font-size: 12px; color: var(--hc-text-muted, #7b899d); }
30+
.empty { padding: 24px; border: 1px dashed var(--hc-border, #2a323e); border-radius: 8px; text-align: center; color: var(--hc-text-muted, #7b899d); }
31+
.err { padding: 16px; border: 1px dashed #b35a5a; border-radius: 8px; color: #f0c0c0; font-size: 13px; }
32+
`;
33+
34+
@state() private domains: ServiceDomainView[] = [];
35+
@state() private error: string | null = null;
36+
@state() private loading = true;
37+
38+
private client = new HomecoreClient({ token: resolveToken() });
39+
40+
connectedCallback(): void {
41+
super.connectedCallback();
42+
void this.refresh();
43+
}
44+
45+
private async refresh(): Promise<void> {
46+
try {
47+
const r = await fetch('/api/services', { headers: { 'Authorization': `Bearer ${resolveToken()}` } });
48+
if (!r.ok) throw new Error(`/api/services -> HTTP ${r.status}`);
49+
this.domains = await r.json();
50+
this.error = null;
51+
} catch (e) {
52+
this.error = e instanceof Error ? e.message : String(e);
53+
} finally {
54+
this.loading = false;
55+
}
56+
void this.client; // suppress unused warning while keeping the import shape consistent
57+
}
58+
59+
render() {
60+
if (this.error) return html`<div class="err">backend unreachable — ${this.error}</div>`;
61+
if (this.loading) return html`<div>loading services…</div>`;
62+
if (this.domains.length === 0) {
63+
return html`
64+
<h1>Services (0 domains)</h1>
65+
<div class="empty">
66+
No services registered. Services are registered by plugins
67+
(Wasmtime or InProcess) or by integrations that call
68+
<code>services::register()</code> on boot.
69+
</div>
70+
`;
71+
}
72+
return html`
73+
<h1>Services (${this.domains.length} domain${this.domains.length === 1 ? '' : 's'})</h1>
74+
${this.domains.map(d => html`
75+
<div class="domain">
76+
<h2>${d.domain}</h2>
77+
<ul>
78+
${Object.keys(d.services).map(name => html`<li>${name}</li>`)}
79+
</ul>
80+
</div>
81+
`)}
82+
`;
83+
}
84+
}
85+
86+
declare global { interface HTMLElementTagNameMap { 'hc-services': ServicesPage; } }

frontend/src/pages/Settings.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* Settings page — backend config + bearer-token editor (localStorage).
3+
*/
4+
5+
import { LitElement, html, css } from 'lit';
6+
import { customElement, state } from 'lit/decorators.js';
7+
8+
import { HomecoreClient } from '../api/client.js';
9+
import type { ApiConfig } from '../api/types.js';
10+
11+
function resolveToken(): string {
12+
if (typeof localStorage !== 'undefined') {
13+
const stored = localStorage.getItem('homecore.token');
14+
if (stored) return stored;
15+
}
16+
const qs = new URL(window.location.href).searchParams.get('token');
17+
return qs ?? 'dev-token';
18+
}
19+
20+
@customElement('hc-settings')
21+
export class SettingsPage extends LitElement {
22+
static styles = css`
23+
:host { display: block; padding: 24px; color: var(--hc-text, #e6eaee); font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif); }
24+
h1 { font-size: 18px; font-weight: 600; margin: 0 0 16px 0; }
25+
section { background: hsl(220 20% 10%); border: 1px solid var(--hc-border, #2a323e); border-radius: 8px; padding: 16px; margin-bottom: 16px; }
26+
h2 { font-size: 14px; font-weight: 600; margin: 0 0 12px 0; color: var(--hc-primary, #19d4e5); }
27+
dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 18px; margin: 0; font-size: 13px; font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); }
28+
dt { color: var(--hc-text-muted, #7b899d); }
29+
dd { margin: 0; }
30+
label { display: block; margin-bottom: 6px; font-size: 13px; color: var(--hc-text-muted, #7b899d); }
31+
input { width: 100%; box-sizing: border-box; padding: 8px 12px; background: hsl(220 25% 14%); border: 1px solid var(--hc-border, #2a323e); border-radius: 6px; color: var(--hc-text, #e6eaee); font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); font-size: 13px; }
32+
button { margin-top: 10px; padding: 8px 16px; background: var(--hc-primary, #19d4e5); color: var(--hc-primary-fg, #0b0e13); border: none; border-radius: 6px; font-weight: 600; font-size: 13px; cursor: pointer; font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif); }
33+
button:hover { background: hsl(185 80% 55%); }
34+
.toast { font-size: 12px; color: var(--hc-primary, #19d4e5); margin-top: 8px; }
35+
.err { padding: 16px; border: 1px dashed #b35a5a; border-radius: 8px; color: #f0c0c0; font-size: 13px; }
36+
`;
37+
38+
@state() private config: ApiConfig | null = null;
39+
@state() private error: string | null = null;
40+
@state() private token = resolveToken();
41+
@state() private savedAt = 0;
42+
43+
private client = new HomecoreClient({ token: resolveToken() });
44+
45+
connectedCallback(): void {
46+
super.connectedCallback();
47+
void this.refresh();
48+
}
49+
50+
private async refresh(): Promise<void> {
51+
try {
52+
this.config = await this.client.getConfig();
53+
this.error = null;
54+
} catch (e) {
55+
this.error = e instanceof Error ? e.message : String(e);
56+
}
57+
}
58+
59+
private saveToken() {
60+
localStorage.setItem('homecore.token', this.token);
61+
this.savedAt = Date.now();
62+
this.client = new HomecoreClient({ token: this.token });
63+
void this.refresh();
64+
}
65+
66+
render() {
67+
return html`
68+
<h1>Settings</h1>
69+
<section>
70+
<h2>backend</h2>
71+
${this.error
72+
? html`<div class="err">unreachable — ${this.error}</div>`
73+
: this.config
74+
? html`<dl>
75+
<dt>location</dt><dd>${this.config.location_name}</dd>
76+
<dt>version</dt><dd>${this.config.version}</dd>
77+
<dt>state</dt><dd>${this.config.state}</dd>
78+
<dt>components</dt><dd>${this.config.components.join(', ')}</dd>
79+
</dl>`
80+
: html`loading…`}
81+
</section>
82+
<section>
83+
<h2>auth — bearer token</h2>
84+
<label for="tok">stored at localStorage["homecore.token"]; DEV mode accepts any non-empty value</label>
85+
<input id="tok" type="password" .value=${this.token}
86+
@input=${(e: Event) => (this.token = (e.target as HTMLInputElement).value)} />
87+
<button @click=${this.saveToken}>save & reload backend</button>
88+
${this.savedAt > 0 ? html`<div class="toast">saved at ${new Date(this.savedAt).toLocaleTimeString()}</div>` : ''}
89+
</section>
90+
`;
91+
}
92+
}
93+
94+
declare global { interface HTMLElementTagNameMap { 'hc-settings': SettingsPage; } }

frontend/src/pages/States.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* States page — full table view of every entity in the state machine.
3+
* Mirrors Home Assistant's `/developer-tools/state` view (read-only).
4+
*/
5+
6+
import { LitElement, html, css } from 'lit';
7+
import { customElement, state } from 'lit/decorators.js';
8+
9+
import { HomecoreClient } from '../api/client.js';
10+
import type { StateView } from '../api/types.js';
11+
12+
function resolveToken(): string {
13+
if (typeof localStorage !== 'undefined') {
14+
const stored = localStorage.getItem('homecore.token');
15+
if (stored) return stored;
16+
}
17+
const qs = new URL(window.location.href).searchParams.get('token');
18+
return qs ?? 'dev-token';
19+
}
20+
21+
@customElement('hc-states')
22+
export class StatesPage extends LitElement {
23+
static styles = css`
24+
:host { display: block; padding: 24px; color: var(--hc-text, #e6eaee); font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif); }
25+
h1 { font-size: 18px; font-weight: 600; margin: 0 0 16px 0; }
26+
table { width: 100%; border-collapse: collapse; font-size: 13px; }
27+
th { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--hc-border, #2a323e); color: var(--hc-text-muted, #7b899d); font-weight: 500; }
28+
td { padding: 10px 12px; border-bottom: 1px solid hsl(220 15% 14%); font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); }
29+
td.attrs { color: var(--hc-text-muted, #7b899d); font-size: 12px; max-width: 380px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
30+
tr:hover td { background: hsl(220 20% 10%); }
31+
.state { color: var(--hc-primary, #19d4e5); }
32+
.err { padding: 16px; border: 1px dashed #b35a5a; border-radius: 8px; color: #f0c0c0; font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); font-size: 13px; }
33+
`;
34+
35+
@state() private states: StateView[] = [];
36+
@state() private error: string | null = null;
37+
@state() private loading = true;
38+
39+
private client = new HomecoreClient({ token: resolveToken() });
40+
private timer?: number;
41+
42+
connectedCallback(): void {
43+
super.connectedCallback();
44+
void this.refresh();
45+
this.timer = window.setInterval(() => void this.refresh(), 5000);
46+
}
47+
disconnectedCallback(): void {
48+
if (this.timer !== undefined) window.clearInterval(this.timer);
49+
super.disconnectedCallback();
50+
}
51+
52+
private async refresh(): Promise<void> {
53+
try {
54+
this.states = await this.client.getStates();
55+
this.error = null;
56+
} catch (e) {
57+
this.error = e instanceof Error ? e.message : String(e);
58+
} finally {
59+
this.loading = false;
60+
}
61+
}
62+
63+
render() {
64+
if (this.error) return html`<div class="err">backend unreachable — ${this.error}</div>`;
65+
if (this.loading) return html`<div>loading…</div>`;
66+
return html`
67+
<h1>States (${this.states.length})</h1>
68+
<table>
69+
<thead><tr><th>entity_id</th><th>state</th><th>last_changed</th><th>attributes</th></tr></thead>
70+
<tbody>
71+
${this.states.map(s => html`
72+
<tr>
73+
<td>${s.entity_id}</td>
74+
<td class="state">${s.state}</td>
75+
<td>${s.last_changed.replace('T', ' ').replace(/\..*$/, '')}</td>
76+
<td class="attrs" title=${JSON.stringify(s.attributes)}>${JSON.stringify(s.attributes)}</td>
77+
</tr>
78+
`)}
79+
</tbody>
80+
</table>
81+
`;
82+
}
83+
}
84+
85+
declare global { interface HTMLElementTagNameMap { 'hc-states': StatesPage; } }

0 commit comments

Comments
 (0)