mirror of
https://github.com/ruvnet/RuView
synced 2026-08-03 19:21:42 +00:00
refactor: move frontend/ to examples/frontend/
The Lit + Vite HOMECORE web UI is an example consumer of the sensing stack, not a top-level deliverable — relocate it under examples/ alongside the other sensor and dashboard demos. Add an entry to examples/README.md so it's discoverable. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Dashboard page — fetches HOMECORE state + config from the backend and
|
||||
* populates the `<hc-app-shell>` slot with a grid of `<hc-state-card>`.
|
||||
*
|
||||
* Auth: reads bearer from `localStorage["homecore.token"]`, the
|
||||
* `?token=` query string, or `HOMECORE_TOKEN` `<meta>` tag — in that
|
||||
* order. Falls back to the literal "dev-token" in DEV-mode backends
|
||||
* (any non-empty bearer is accepted when HOMECORE_TOKENS is unset).
|
||||
*/
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state, query } from 'lit/decorators.js';
|
||||
|
||||
import { HomecoreClient } from '../api/client.js';
|
||||
import type { ApiConfig, StateView } from '../api/types.js';
|
||||
import '../components/Modal.js';
|
||||
import '../components/EntityForm.js';
|
||||
import type { EntityForm } from '../components/EntityForm.js';
|
||||
|
||||
function resolveToken(): string {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const stored = localStorage.getItem('homecore.token');
|
||||
if (stored) return stored;
|
||||
}
|
||||
const url = new URL(window.location.href);
|
||||
const qs = url.searchParams.get('token');
|
||||
if (qs) return qs;
|
||||
const meta = document.querySelector<HTMLMetaElement>('meta[name="homecore-token"]');
|
||||
if (meta?.content) return meta.content;
|
||||
return 'dev-token';
|
||||
}
|
||||
|
||||
@customElement('hc-dashboard')
|
||||
export class Dashboard extends LitElement {
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 24px;
|
||||
color: var(--hc-fg, #e6e9ec);
|
||||
font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif);
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
color: var(--hc-fg-dim, #8a93a0);
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.meta strong { color: var(--hc-fg, #e6e9ec); }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.empty,
|
||||
.err {
|
||||
padding: 24px;
|
||||
border: 1px dashed var(--hc-border, #2a323e);
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
color: var(--hc-fg-dim, #8a93a0);
|
||||
}
|
||||
.err {
|
||||
border-color: #b35a5a;
|
||||
color: #f0c0c0;
|
||||
text-align: left;
|
||||
font-family: var(--hc-font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 13px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
|
||||
.toolbar .grow { flex: 1; }
|
||||
button.add {
|
||||
padding: 7px 14px;
|
||||
background: var(--hc-primary, #19d4e5);
|
||||
color: var(--hc-primary-fg, #0b0e13);
|
||||
border: none; border-radius: 6px;
|
||||
font-size: 13px; font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif);
|
||||
}
|
||||
button.add:hover { background: hsl(185 80% 55%); }
|
||||
button.btn {
|
||||
padding: 7px 14px;
|
||||
background: hsl(220 25% 14%);
|
||||
color: var(--hc-text, #e6eaee);
|
||||
border: 1px solid var(--hc-border, #2a323e);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif);
|
||||
}
|
||||
button.btn:hover { background: hsl(220 20% 18%); }
|
||||
button.primary { background: var(--hc-primary, #19d4e5); color: var(--hc-primary-fg, #0b0e13); border-color: var(--hc-primary, #19d4e5); font-weight: 600; }
|
||||
.toast { padding: 8px 12px; background: hsl(165 60% 16%); color: hsl(165 60% 80%); border-radius: 6px; font-size: 12px; margin-bottom: 12px; }
|
||||
`;
|
||||
|
||||
@state() private states: StateView[] = [];
|
||||
@state() private config: ApiConfig | null = null;
|
||||
@state() private error: string | null = null;
|
||||
@state() private loading = true;
|
||||
@state() private modalOpen = false;
|
||||
@state() private submitToast: string | null = null;
|
||||
@state() private editingState: StateView | null = null; // null = create mode
|
||||
@state() private deletingState: StateView | null = null; // null = no confirm
|
||||
|
||||
@query('hc-entity-form') private _form?: EntityForm;
|
||||
|
||||
private client = new HomecoreClient({ token: resolveToken() });
|
||||
private pollTimer: number | undefined;
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
void this.refresh();
|
||||
this.pollTimer = window.setInterval(() => void this.refresh(), 5000);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
if (this.pollTimer !== undefined) window.clearInterval(this.pollTimer);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private async refresh(): Promise<void> {
|
||||
try {
|
||||
const [cfg, states] = await Promise.all([
|
||||
this.client.getConfig(),
|
||||
this.client.getStates(),
|
||||
]);
|
||||
this.config = cfg;
|
||||
this.states = states;
|
||||
this.error = null;
|
||||
} catch (e) {
|
||||
this.error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _openCreate() {
|
||||
this.editingState = null;
|
||||
this.modalOpen = true;
|
||||
}
|
||||
|
||||
private _openEdit(e: CustomEvent<{ state: StateView }>) {
|
||||
this.editingState = e.detail.state;
|
||||
this.modalOpen = true;
|
||||
}
|
||||
|
||||
private _openDeleteConfirm(e: CustomEvent<{ state: StateView }>) {
|
||||
this.deletingState = e.detail.state;
|
||||
}
|
||||
|
||||
private async _confirmDelete() {
|
||||
const target = this.deletingState;
|
||||
if (!target) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/states/${encodeURIComponent(target.entity_id)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${resolveToken()}` },
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
|
||||
this.deletingState = null;
|
||||
this.submitToast = `Deleted ${target.entity_id}`;
|
||||
window.setTimeout(() => (this.submitToast = null), 3000);
|
||||
await this.refresh();
|
||||
} catch (err) {
|
||||
this.error = err instanceof Error ? err.message : String(err);
|
||||
this.deletingState = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async _onSubmit(e: CustomEvent<{ entity_id: string; state: string; attributes: Record<string, unknown> }>) {
|
||||
const { entity_id, state, attributes } = e.detail;
|
||||
const wasEditing = this.editingState !== null;
|
||||
// Clear any previous server-side error before the next attempt.
|
||||
this._form?.setSubmitError(null);
|
||||
try {
|
||||
const resp = await fetch(`/api/states/${encodeURIComponent(entity_id)}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${resolveToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ state, attributes }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
// Surface the server message inline in the form, not at
|
||||
// the top of the page — the form is what the user is
|
||||
// looking at.
|
||||
const body = await resp.text();
|
||||
this._form?.setSubmitError(`server rejected (${resp.status}): ${body || resp.statusText}`);
|
||||
return;
|
||||
}
|
||||
this.modalOpen = false;
|
||||
this.editingState = null;
|
||||
this.submitToast = `${wasEditing ? 'Updated' : 'Created'} ${entity_id} = ${state}`;
|
||||
window.setTimeout(() => (this.submitToast = null), 3000);
|
||||
await this.refresh();
|
||||
} catch (err) {
|
||||
this._form?.setSubmitError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.error && this.states.length === 0) {
|
||||
return html`<div class="err">backend unreachable — ${this.error}\n\n
|
||||
hint: make sure homecore-server is running on :8123 and that
|
||||
the token in localStorage["homecore.token"] is accepted.
|
||||
</div>`;
|
||||
}
|
||||
if (this.loading) {
|
||||
return html`<div class="empty">loading HOMECORE state…</div>`;
|
||||
}
|
||||
const v = this.config?.version ?? '?';
|
||||
const loc = this.config?.location_name ?? 'Home';
|
||||
return html`
|
||||
${this.submitToast ? html`<div class="toast">${this.submitToast}</div>` : ''}
|
||||
<div class="toolbar">
|
||||
<span class="grow"></span>
|
||||
<button class="add" @click=${this._openCreate}>+ Add entity</button>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span><strong>${loc}</strong></span>
|
||||
<span>HOMECORE v<strong>${v}</strong></span>
|
||||
<span><strong>${this.states.length}</strong> entities</span>
|
||||
</div>
|
||||
${this.states.length === 0
|
||||
? html`<div class="empty">
|
||||
No entities registered yet. Click <strong>+ Add entity</strong>
|
||||
above, run <code>bash scripts/homecore-seed.sh</code>,
|
||||
or boot <code>homecore-server</code> without
|
||||
<code>--no-seed-entities</code>.
|
||||
</div>`
|
||||
: html`<div class="grid"
|
||||
@hc-state-card-click=${(e: Event) => this._openEdit(e as CustomEvent)}
|
||||
@hc-state-card-delete=${(e: Event) => this._openDeleteConfirm(e as CustomEvent)}>
|
||||
${this.states.map(
|
||||
(s) => html`<hc-state-card .state=${s}></hc-state-card>`
|
||||
)}
|
||||
</div>`}
|
||||
|
||||
<hc-modal .open=${this.deletingState !== null}
|
||||
heading="Delete entity"
|
||||
@hc-modal-close=${() => (this.deletingState = null)}>
|
||||
<p style="margin:0 0 12px 0; line-height:1.5;">
|
||||
Permanently remove
|
||||
<code style="background:hsl(220 25% 14%); padding:2px 6px; border-radius:4px;">${this.deletingState?.entity_id ?? ''}</code>
|
||||
from the state machine?
|
||||
<br>
|
||||
<span style="color:var(--hc-text-muted,#7b899d); font-size:12px;">
|
||||
This is immediate. To restore, re-create the entity via "+ Add entity".
|
||||
</span>
|
||||
</p>
|
||||
<button slot="footer" class="btn" @click=${() => (this.deletingState = null)}>Cancel</button>
|
||||
<button slot="footer" class="btn"
|
||||
style="background:hsl(0 50% 25%); border-color:hsl(0 50% 35%); color:hsl(0 60% 88%);"
|
||||
@click=${this._confirmDelete}>Delete</button>
|
||||
</hc-modal>
|
||||
|
||||
<hc-modal .open=${this.modalOpen}
|
||||
heading=${this.editingState ? `Edit ${this.editingState.entity_id}` : 'Add entity'}
|
||||
@hc-modal-close=${() => { this.modalOpen = false; this.editingState = null; }}>
|
||||
<hc-entity-form
|
||||
.entityId=${this.editingState?.entity_id ?? ''}
|
||||
.state=${this.editingState?.state ?? ''}
|
||||
.entityAttrs=${this.editingState?.attributes ?? {}}
|
||||
.editing=${this.editingState !== null}
|
||||
@hc-entity-submit=${(e: Event) => this._onSubmit(e as CustomEvent)}
|
||||
@hc-entity-cancel=${() => { this.modalOpen = false; this.editingState = null; }}></hc-entity-form>
|
||||
<button slot="footer" class="btn" @click=${() => this._form?.requestCancel()}>Cancel</button>
|
||||
<button slot="footer" class="btn primary" @click=${() => this._form?.requestSubmit()}>${this.editingState ? 'Save' : 'Create'}</button>
|
||||
</hc-modal>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'hc-dashboard': Dashboard;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Services page — lists every registered service grouped by domain,
|
||||
* and lets the operator call any of them with a JSON service_data
|
||||
* payload (POST /api/services/<domain>/<service>).
|
||||
*/
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
|
||||
import type { ServiceDomainView } from '../api/types.js';
|
||||
import '../components/Modal.js';
|
||||
|
||||
function resolveToken(): string {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const stored = localStorage.getItem('homecore.token');
|
||||
if (stored) return stored;
|
||||
}
|
||||
const qs = new URL(window.location.href).searchParams.get('token');
|
||||
return qs ?? 'dev-token';
|
||||
}
|
||||
|
||||
@customElement('hc-services')
|
||||
export class ServicesPage extends LitElement {
|
||||
static styles = css`
|
||||
:host { display: block; padding: 24px; color: var(--hc-text, #e6eaee); font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif); }
|
||||
h1 { font-size: 18px; font-weight: 600; margin: 0 0 16px 0; }
|
||||
.domain { background: hsl(220 20% 10%); border: 1px solid var(--hc-border, #2a323e); border-radius: 8px; margin-bottom: 12px; padding: 14px 16px; }
|
||||
.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); }
|
||||
ul { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
li {
|
||||
background: hsl(220 25% 14%);
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
font-family: var(--hc-font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 12px;
|
||||
color: var(--hc-text-muted, #7b899d);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
li .name { padding: 4px 10px; }
|
||||
li button.call {
|
||||
background: hsl(220 25% 18%);
|
||||
color: var(--hc-primary, #19d4e5);
|
||||
border: none;
|
||||
border-left: 1px solid var(--hc-border, #2a323e);
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif);
|
||||
font-weight: 600;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
li button.call:hover { background: var(--hc-primary, #19d4e5); color: var(--hc-primary-fg, #0b0e13); }
|
||||
.empty { padding: 24px; border: 1px dashed var(--hc-border, #2a323e); border-radius: 8px; text-align: center; color: var(--hc-text-muted, #7b899d); }
|
||||
.err { padding: 16px; border: 1px dashed #b35a5a; border-radius: 8px; color: #f0c0c0; font-size: 13px; }
|
||||
.toast { padding: 8px 12px; background: hsl(165 60% 16%); color: hsl(165 60% 80%); border-radius: 6px; font-size: 12px; margin-bottom: 12px; }
|
||||
|
||||
/* Service-call modal contents */
|
||||
.form label { display: block; margin: 6px 0 4px; font-size: 12px; color: var(--hc-text-muted, #7b899d); }
|
||||
.form code.target { color: var(--hc-primary, #19d4e5); font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); font-size: 13px; }
|
||||
.form textarea {
|
||||
width: 100%; box-sizing: border-box;
|
||||
padding: 8px 10px; background: hsl(220 25% 10%);
|
||||
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;
|
||||
min-height: 90px;
|
||||
resize: vertical;
|
||||
}
|
||||
.form textarea.invalid { border-color: hsl(0 60% 50%); }
|
||||
.form .hint { font-size: 11px; color: var(--hc-text-muted, #7b899d); margin-top: 4px; }
|
||||
.form .field-status { font-size: 11px; margin-top: 4px; }
|
||||
.form .field-status.ok { color: hsl(150 60% 55%); }
|
||||
.form .field-status.err { color: hsl(0 70% 70%); }
|
||||
.form pre {
|
||||
background: hsl(220 25% 8%);
|
||||
border: 1px solid var(--hc-border, #2a323e);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
font-family: var(--hc-font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.form .resp-ok { border-color: hsl(150 50% 35%); }
|
||||
.form .resp-err { border-color: hsl(0 50% 45%); color: #f0c0c0; }
|
||||
.form .err { padding: 10px; margin-top: 10px; border: 1px solid #b35a5a; border-radius: 6px; background: hsl(0 35% 12%); color: #f0c0c0; font-size: 12px; }
|
||||
|
||||
button.btn {
|
||||
padding: 8px 16px;
|
||||
background: hsl(220 25% 14%);
|
||||
color: var(--hc-text, #e6eaee);
|
||||
border: 1px solid var(--hc-border, #2a323e);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif);
|
||||
}
|
||||
button.btn:hover { background: hsl(220 20% 18%); }
|
||||
button.btn.primary { background: var(--hc-primary, #19d4e5); color: var(--hc-primary-fg, #0b0e13); border-color: var(--hc-primary, #19d4e5); font-weight: 600; }
|
||||
button.btn.primary[disabled] { background: hsl(220 15% 20%); color: var(--hc-text-muted, #7b899d); border-color: var(--hc-border, #2a323e); cursor: not-allowed; }
|
||||
`;
|
||||
|
||||
@state() private domains: ServiceDomainView[] = [];
|
||||
@state() private error: string | null = null;
|
||||
@state() private loading = true;
|
||||
@state() private calling: { domain: string; service: string } | null = null;
|
||||
@state() private callBody = '{}';
|
||||
@state() private callResp: { ok: boolean; text: string } | null = null;
|
||||
@state() private callErr: string | null = null;
|
||||
@state() private callPending = false;
|
||||
@state() private callToast: string | null = null;
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
void this.refresh();
|
||||
}
|
||||
|
||||
private async refresh(): Promise<void> {
|
||||
try {
|
||||
const r = await fetch('/api/services', { headers: { 'Authorization': `Bearer ${resolveToken()}` } });
|
||||
if (!r.ok) throw new Error(`/api/services -> HTTP ${r.status}`);
|
||||
this.domains = await r.json();
|
||||
this.error = null;
|
||||
} catch (e) {
|
||||
this.error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _openCall(domain: string, service: string) {
|
||||
this.calling = { domain, service };
|
||||
this.callBody = '{}';
|
||||
this.callResp = null;
|
||||
this.callErr = null;
|
||||
}
|
||||
|
||||
private _closeCall() {
|
||||
this.calling = null;
|
||||
this.callBody = '{}';
|
||||
this.callResp = null;
|
||||
this.callErr = null;
|
||||
this.callPending = false;
|
||||
}
|
||||
|
||||
private _validateBody(): { ok: boolean; data?: unknown; msg?: string } {
|
||||
const raw = this.callBody.trim();
|
||||
if (!raw) return { ok: true, data: {} };
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) {
|
||||
return { ok: false, msg: 'service_data must be a JSON object (not array, not scalar)' };
|
||||
}
|
||||
return { ok: true, data: parsed };
|
||||
} catch (e) {
|
||||
return { ok: false, msg: `JSON parse: ${e instanceof Error ? e.message : String(e)}` };
|
||||
}
|
||||
}
|
||||
|
||||
private async _doCall() {
|
||||
if (!this.calling) return;
|
||||
const v = this._validateBody();
|
||||
if (!v.ok) {
|
||||
this.callErr = v.msg ?? 'invalid';
|
||||
this.callResp = null;
|
||||
return;
|
||||
}
|
||||
this.callPending = true;
|
||||
this.callErr = null;
|
||||
const { domain, service } = this.calling;
|
||||
try {
|
||||
const r = await fetch(`/api/services/${encodeURIComponent(domain)}/${encodeURIComponent(service)}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${resolveToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(v.data ?? {}),
|
||||
});
|
||||
const text = await r.text();
|
||||
if (r.ok) {
|
||||
let pretty = text;
|
||||
try { pretty = JSON.stringify(JSON.parse(text), null, 2); } catch { /* leave raw */ }
|
||||
this.callResp = { ok: true, text: pretty };
|
||||
this.callToast = `Called ${domain}.${service} → 200`;
|
||||
window.setTimeout(() => (this.callToast = null), 3000);
|
||||
} else {
|
||||
this.callResp = { ok: false, text: `HTTP ${r.status}\n${text}` };
|
||||
}
|
||||
} catch (e) {
|
||||
this.callErr = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
this.callPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.error) return html`<div class="err">backend unreachable — ${this.error}</div>`;
|
||||
if (this.loading) return html`<div>loading services…</div>`;
|
||||
if (this.domains.length === 0) {
|
||||
return html`
|
||||
<h1>Services (0 domains)</h1>
|
||||
<div class="empty">
|
||||
No services registered. Services are registered by plugins
|
||||
(Wasmtime or InProcess) or by integrations that call
|
||||
<code>services::register()</code> on boot.
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
const validity = this._validateBody();
|
||||
return html`
|
||||
${this.callToast ? html`<div class="toast">${this.callToast}</div>` : ''}
|
||||
<h1>Services (${this.domains.length} domain${this.domains.length === 1 ? '' : 's'})</h1>
|
||||
${this.domains.map(d => html`
|
||||
<div class="domain">
|
||||
<h2>${d.domain}</h2>
|
||||
<ul>
|
||||
${Object.keys(d.services).map(name => html`
|
||||
<li>
|
||||
<span class="name">${name}</span>
|
||||
<button class="call"
|
||||
@click=${() => this._openCall(d.domain, name)}
|
||||
title="Call ${d.domain}.${name}">▶ Call</button>
|
||||
</li>
|
||||
`)}
|
||||
</ul>
|
||||
</div>
|
||||
`)}
|
||||
|
||||
<hc-modal .open=${this.calling !== null}
|
||||
heading=${this.calling ? `Call ${this.calling.domain}.${this.calling.service}` : ''}
|
||||
@hc-modal-close=${this._closeCall}>
|
||||
<div class="form">
|
||||
<label>target</label>
|
||||
<div><code class="target">POST /api/services/${this.calling?.domain ?? ''}/${this.calling?.service ?? ''}</code></div>
|
||||
|
||||
<label for="body">service_data (JSON object)</label>
|
||||
<textarea id="body"
|
||||
class=${validity.ok ? '' : 'invalid'}
|
||||
.value=${this.callBody}
|
||||
@input=${(e: Event) => (this.callBody = (e.target as HTMLTextAreaElement).value)}
|
||||
placeholder='{ "entity_id": "light.kitchen_ceiling", "brightness": 200 }'></textarea>
|
||||
<div class="hint">leave blank for <code>{}</code> — these handlers are no-op echoes, they round-trip whatever you send</div>
|
||||
${validity.ok
|
||||
? (this.callBody.trim()
|
||||
? html`<div class="field-status ok">✓ service_data OK</div>`
|
||||
: html`<div class="hint">empty → will send <code>{}</code></div>`)
|
||||
: html`<div class="field-status err">✗ ${validity.msg}</div>`}
|
||||
|
||||
${this.callErr ? html`<div class="err">${this.callErr}</div>` : ''}
|
||||
${this.callResp
|
||||
? html`<label>response</label>
|
||||
<pre class=${this.callResp.ok ? 'resp-ok' : 'resp-err'}>${this.callResp.text}</pre>`
|
||||
: ''}
|
||||
</div>
|
||||
<button slot="footer" class="btn" @click=${this._closeCall}>Close</button>
|
||||
<button slot="footer" class="btn primary"
|
||||
?disabled=${!validity.ok || this.callPending}
|
||||
@click=${this._doCall}>
|
||||
${this.callPending ? 'Calling…' : 'Call'}
|
||||
</button>
|
||||
</hc-modal>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global { interface HTMLElementTagNameMap { 'hc-services': ServicesPage; } }
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Settings page — backend config + bearer-token editor with
|
||||
* probe-before-persist validation.
|
||||
*
|
||||
* The save flow probes `/api/config` with the new token BEFORE writing
|
||||
* it to localStorage. If the probe fails (401 wrong token, network
|
||||
* error, etc.) the bad token is NOT persisted and the operator sees
|
||||
* an inline error. This avoids the foot-gun where saving a typo'd
|
||||
* token would lock the UI out of the backend until the operator
|
||||
* cleared localStorage by hand.
|
||||
*/
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
|
||||
import { HomecoreClient } from '../api/client.js';
|
||||
import type { ApiConfig } from '../api/types.js';
|
||||
|
||||
const TOKEN_LS_KEY = 'homecore.token';
|
||||
|
||||
function resolveToken(): string {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const stored = localStorage.getItem(TOKEN_LS_KEY);
|
||||
if (stored) return stored;
|
||||
}
|
||||
const qs = new URL(window.location.href).searchParams.get('token');
|
||||
return qs ?? 'dev-token';
|
||||
}
|
||||
|
||||
function maskToken(t: string): string {
|
||||
if (!t) return '(empty)';
|
||||
if (t.length <= 8) return '•'.repeat(t.length);
|
||||
return t.slice(0, 4) + '…' + t.slice(-3) + ' (' + t.length + ' chars)';
|
||||
}
|
||||
|
||||
type ProbeResult =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'probing' }
|
||||
| { kind: 'ok'; ms: number; serverVersion: string }
|
||||
| { kind: 'err'; status?: number; msg: string };
|
||||
|
||||
@customElement('hc-settings')
|
||||
export class SettingsPage extends LitElement {
|
||||
static styles = css`
|
||||
:host { display: block; padding: 24px; color: var(--hc-text, #e6eaee); font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif); }
|
||||
h1 { font-size: 18px; font-weight: 600; margin: 0 0 16px 0; }
|
||||
section { background: hsl(220 20% 10%); border: 1px solid var(--hc-border, #2a323e); border-radius: 8px; padding: 16px; margin-bottom: 16px; }
|
||||
h2 { font-size: 14px; font-weight: 600; margin: 0 0 12px 0; color: var(--hc-primary, #19d4e5); }
|
||||
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); }
|
||||
dt { color: var(--hc-text-muted, #7b899d); }
|
||||
dd { margin: 0; word-break: break-all; }
|
||||
label { display: block; margin-bottom: 6px; font-size: 13px; color: var(--hc-text-muted, #7b899d); }
|
||||
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;
|
||||
}
|
||||
input:focus { outline: 2px solid hsl(185 80% 50% / 0.5); border-color: var(--hc-primary, #19d4e5); }
|
||||
input.invalid { border-color: hsl(0 60% 50%); }
|
||||
.actions { margin-top: 10px; display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
button {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--hc-border, #2a323e);
|
||||
background: hsl(220 25% 14%);
|
||||
color: var(--hc-text, #e6eaee);
|
||||
font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { background: hsl(220 20% 18%); }
|
||||
button.primary { background: var(--hc-primary, #19d4e5); color: var(--hc-primary-fg, #0b0e13); border-color: var(--hc-primary, #19d4e5); font-weight: 600; }
|
||||
button.primary:hover { background: hsl(185 80% 55%); }
|
||||
button[disabled] { background: hsl(220 15% 20%); color: var(--hc-text-muted, #7b899d); cursor: not-allowed; }
|
||||
.hint { font-size: 11px; color: var(--hc-text-muted, #7b899d); margin-top: 6px; }
|
||||
.field-status { font-size: 12px; margin-top: 6px; display: flex; align-items: center; gap: 6px; }
|
||||
.field-status.ok { color: hsl(150 60% 55%); }
|
||||
.field-status.err { color: hsl(0 70% 70%); }
|
||||
.field-status.probing { color: var(--hc-text-muted, #7b899d); }
|
||||
.toast { font-size: 12px; color: var(--hc-primary, #19d4e5); margin-top: 8px; }
|
||||
.err { padding: 12px; border: 1px solid #b35a5a; border-radius: 6px; color: #f0c0c0; background: hsl(0 35% 12%); font-size: 13px; margin-top: 8px; }
|
||||
.saved-meta { font-size: 11px; color: var(--hc-text-muted, #7b899d); margin-top: 4px; font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); }
|
||||
`;
|
||||
|
||||
@state() private config: ApiConfig | null = null;
|
||||
@state() private configErr: string | null = null;
|
||||
@state() private token = resolveToken();
|
||||
@state() private storedToken = resolveToken();
|
||||
@state() private probe: ProbeResult = { kind: 'idle' };
|
||||
@state() private savedAt = 0;
|
||||
|
||||
private client = new HomecoreClient({ token: resolveToken() });
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
void this.refreshConfig();
|
||||
}
|
||||
|
||||
private async refreshConfig(): Promise<void> {
|
||||
try {
|
||||
this.config = await this.client.getConfig();
|
||||
this.configErr = null;
|
||||
} catch (e) {
|
||||
this.configErr = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Hit /api/config with the given token; return success or 4xx/5xx kind. */
|
||||
private async _probe(token: string): Promise<ProbeResult> {
|
||||
if (!token.trim()) return { kind: 'err', msg: 'token must not be empty' };
|
||||
const started = performance.now();
|
||||
try {
|
||||
const r = await fetch('/api/config', {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
if (!r.ok) {
|
||||
return { kind: 'err', status: r.status, msg: r.statusText || `HTTP ${r.status}` };
|
||||
}
|
||||
const cfg = await r.json() as ApiConfig;
|
||||
return { kind: 'ok', ms: Math.round(performance.now() - started), serverVersion: cfg.version };
|
||||
} catch (e) {
|
||||
return { kind: 'err', msg: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
private async _testToken() {
|
||||
this.probe = { kind: 'probing' };
|
||||
this.probe = await this._probe(this.token);
|
||||
}
|
||||
|
||||
private async _saveToken() {
|
||||
const result = await this._probe(this.token);
|
||||
this.probe = result;
|
||||
if (result.kind !== 'ok') return; // refuse to persist a bad token
|
||||
localStorage.setItem(TOKEN_LS_KEY, this.token);
|
||||
this.storedToken = this.token;
|
||||
this.savedAt = Date.now();
|
||||
// Rebuild the client with the new token + refresh the config readout.
|
||||
this.client = new HomecoreClient({ token: this.token });
|
||||
await this.refreshConfig();
|
||||
}
|
||||
|
||||
private _clearToken() {
|
||||
localStorage.removeItem(TOKEN_LS_KEY);
|
||||
this.storedToken = '';
|
||||
this.token = '';
|
||||
this.probe = { kind: 'idle' };
|
||||
this.savedAt = 0;
|
||||
}
|
||||
|
||||
private _renderProbe() {
|
||||
switch (this.probe.kind) {
|
||||
case 'idle':
|
||||
return html`<div class="hint">click Test token to probe /api/config with the value above</div>`;
|
||||
case 'probing':
|
||||
return html`<div class="field-status probing">⋯ probing /api/config…</div>`;
|
||||
case 'ok':
|
||||
return html`<div class="field-status ok">✓ token accepted (${this.probe.ms} ms) — server v${this.probe.serverVersion}</div>`;
|
||||
case 'err':
|
||||
return html`<div class="field-status err">✗ ${this.probe.status ? `HTTP ${this.probe.status}: ` : ''}${this.probe.msg}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const isEmpty = !this.token.trim();
|
||||
const inputClass = isEmpty || this.probe.kind === 'err' ? 'invalid' : '';
|
||||
return html`
|
||||
<h1>Settings</h1>
|
||||
<section>
|
||||
<h2>backend</h2>
|
||||
${this.configErr
|
||||
? html`<div class="err">unreachable — ${this.configErr}</div>`
|
||||
: this.config
|
||||
? html`<dl>
|
||||
<dt>location</dt><dd>${this.config.location_name}</dd>
|
||||
<dt>version</dt><dd>${this.config.version}</dd>
|
||||
<dt>state</dt><dd>${this.config.state}</dd>
|
||||
<dt>components</dt><dd>${this.config.components.join(', ')}</dd>
|
||||
</dl>`
|
||||
: html`loading…`}
|
||||
</section>
|
||||
<section>
|
||||
<h2>auth — bearer token</h2>
|
||||
<label for="tok">localStorage["homecore.token"] — must be accepted by /api/config before save is allowed</label>
|
||||
<input id="tok" type="password" .value=${this.token}
|
||||
class=${inputClass}
|
||||
@input=${(e: Event) => { this.token = (e.target as HTMLInputElement).value; this.probe = { kind: 'idle' }; }} />
|
||||
<div class="saved-meta">currently stored: ${maskToken(this.storedToken)}</div>
|
||||
${this._renderProbe()}
|
||||
<div class="actions">
|
||||
<button @click=${this._testToken} ?disabled=${isEmpty}>Test token</button>
|
||||
<button class="primary" @click=${this._saveToken} ?disabled=${isEmpty}>Probe & Save</button>
|
||||
<button @click=${this._clearToken}>Clear</button>
|
||||
</div>
|
||||
${this.savedAt > 0
|
||||
? html`<div class="toast">✓ saved at ${new Date(this.savedAt).toLocaleTimeString()} — backend config refreshed with new token</div>`
|
||||
: ''}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global { interface HTMLElementTagNameMap { 'hc-settings': SettingsPage; } }
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* States page — full table view of every entity in the state machine.
|
||||
* Mirrors Home Assistant's `/developer-tools/state` view (read-only).
|
||||
*/
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
|
||||
import { HomecoreClient } from '../api/client.js';
|
||||
import type { StateView } from '../api/types.js';
|
||||
|
||||
function resolveToken(): string {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const stored = localStorage.getItem('homecore.token');
|
||||
if (stored) return stored;
|
||||
}
|
||||
const qs = new URL(window.location.href).searchParams.get('token');
|
||||
return qs ?? 'dev-token';
|
||||
}
|
||||
|
||||
@customElement('hc-states')
|
||||
export class StatesPage extends LitElement {
|
||||
static styles = css`
|
||||
:host { display: block; padding: 24px; color: var(--hc-text, #e6eaee); font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif); }
|
||||
h1 { font-size: 18px; font-weight: 600; margin: 0 0 16px 0; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--hc-border, #2a323e); color: var(--hc-text-muted, #7b899d); font-weight: 500; }
|
||||
td { padding: 10px 12px; border-bottom: 1px solid hsl(220 15% 14%); font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); }
|
||||
td.attrs { color: var(--hc-text-muted, #7b899d); font-size: 12px; max-width: 380px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
tr:hover td { background: hsl(220 20% 10%); }
|
||||
.state { color: var(--hc-primary, #19d4e5); }
|
||||
.err { padding: 16px; border: 1px dashed #b35a5a; border-radius: 8px; color: #f0c0c0; font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); font-size: 13px; }
|
||||
`;
|
||||
|
||||
@state() private states: StateView[] = [];
|
||||
@state() private error: string | null = null;
|
||||
@state() private loading = true;
|
||||
|
||||
private client = new HomecoreClient({ token: resolveToken() });
|
||||
private timer?: number;
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
void this.refresh();
|
||||
this.timer = window.setInterval(() => void this.refresh(), 5000);
|
||||
}
|
||||
disconnectedCallback(): void {
|
||||
if (this.timer !== undefined) window.clearInterval(this.timer);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private async refresh(): Promise<void> {
|
||||
try {
|
||||
this.states = await this.client.getStates();
|
||||
this.error = null;
|
||||
} catch (e) {
|
||||
this.error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.error) return html`<div class="err">backend unreachable — ${this.error}</div>`;
|
||||
if (this.loading) return html`<div>loading…</div>`;
|
||||
return html`
|
||||
<h1>States (${this.states.length})</h1>
|
||||
<table>
|
||||
<thead><tr><th>entity_id</th><th>state</th><th>last_changed</th><th>attributes</th></tr></thead>
|
||||
<tbody>
|
||||
${this.states.map(s => html`
|
||||
<tr>
|
||||
<td>${s.entity_id}</td>
|
||||
<td class="state">${s.state}</td>
|
||||
<td>${s.last_changed.replace('T', ' ').replace(/\..*$/, '')}</td>
|
||||
<td class="attrs" title=${JSON.stringify(s.attributes)}>${JSON.stringify(s.attributes)}</td>
|
||||
</tr>
|
||||
`)}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global { interface HTMLElementTagNameMap { 'hc-states': StatesPage; } }
|
||||
Reference in New Issue
Block a user