Compare commits

...

2 Commits

Author SHA1 Message Date
ruv 99c78f512c feat(homecore-ui iter 5): Call Service from Services page
CRUD increment 5/6. Each service pill on the Services page now has
a `▶ Call` button that opens a modal letting the operator POST a
JSON service_data payload to /api/services/<domain>/<service> and
inspect the round-tripped response.

Modal contents:
  - heading "Call <domain>.<service>"
  - target URL displayed as code (POST /api/services/...)
  - service_data JSON textarea (default `{}`, live-validated as
    JSON object — same rules as EntityForm.attributes)
  - response <pre> block: green border on 2xx, red on non-2xx,
    pretty-printed JSON when parseable
  - Close + Call buttons in footer; Call disabled on invalid JSON
    or while pending; renders "Calling…" briefly during the POST

Reuses `<hc-modal>` from iter 1. No new components — all of iter 5
lives in `frontend/src/pages/Services.ts` (~140 LOC delta).

Browser-verified end-to-end against homecore-server (13 services
seeded across 6 domains):
  - 13/13 service pills have a `▶ Call` button
  - Modal opens with correct heading and target URL
  - Live validation: [1,2,3] → red "must be a JSON object";
    `{broken json:` → red "JSON parse: …"; valid → green ✓
  - Call button disabled on invalid input
  - Successful call: green-bordered response containing
    {"called":"switch.turn_on", "acknowledged":true,
     "service_data":{"entity_id":"light.kitchen_ceiling","brightness":200}}
  - Toast "Called switch.turn_on → 200"
  - homecore.ping with empty body (default {}) succeeds too
  - 0 console errors related to this flow

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-26 15:27:48 -04:00
ruv 3f5a7411db feat(homecore-ui iter 4): live per-field validation + inline server errors
CRUD increment 4/6. The form now shows validity feedback on every
keystroke instead of only on Create click, makes the warning vs error
distinction visible (amber vs red), and propagates backend 4xx
responses into the form's own error surface.

frontend/src/components/EntityForm.ts (~80 LOC delta):

  - Three new @state fields tracking per-field validity: _idValid,
    _stateValid, _attrsValid (each is `{ok:true} | {ok:false, level:
    'err'|'warn', msg}` or null when untouched).
  - Pure validators outside the class so they can be unit-tested:
    validateEntityId, validateState, validateAttrs.
  - validateEntityId now warns (amber, not red) if the domain prefix
    is outside the standard HA set. KNOWN_DOMAINS lists ~40 standard
    domains (sensor, light, switch, binary_sensor, climate, cover,
    fan, media_player, lock, camera, vacuum, climate, scene, script,
    automation, input_*, person, device_tracker, zone, weather, etc.)
    + homecore-native domain. Unknown domains create entities anyway
    (backend regex still passes them) but the operator sees the soft
    signal.
  - Sigils render below each field: ✓ green when ok, ✗ red on err,
    ! amber on warn. Field borders adopt the level color via
    .invalid / .warn classes.
  - New public method `isValid()` so the host can bind a disabled
    state on its Save button (unused for now; ready for a follow-up).
  - New public method `setSubmitError(msg)` so the host can surface
    server-side rejection text inline in the form's red error block,
    not just at the page top.

frontend/src/pages/Dashboard.ts (small delta):

  - `_onSubmit()` now calls `this._form?.setSubmitError(null)` before
    each attempt to clear stale text, and on non-2xx responses it
    surfaces the server's body text inline via `setSubmitError`.
    Page-top error block is no longer hijacked for form errors.

Browser-verified end-to-end (real homecore-server :8123):

  entity_id field:
    BadID            → red border + "must match domain.snake_case…"
    light.kitchen_test → green ✓ "entity_id OK"
    madeup_domain.foo → amber border + "unknown domain 'madeup_domain' — HA-standard…"

  state field:
    empty            → red ✗ required
    "on"             → green ✓

  attributes field:
    empty            → green ✓ (defaults to {})
    [1,2,3]          → red ✗ "must be a JSON object…"
    {"key":          → red ✗ "JSON parse: Unexpected end of JSON input"
    {"friendly_name":"Test"} → green ✓

  Server-error inline:
    Force 401 via wrong token → form red block shows
      "server rejected (401): unauthorized"

  Successful create: still works, toast still shown, 0 console errors.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-26 15:12:48 -04:00
3 changed files with 324 additions and 13 deletions
+119 -3
View File
@@ -22,6 +22,63 @@ import { customElement, property, state } from 'lit/decorators.js';
const ENTITY_ID_RE = /^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/;
/**
* Known Home Assistant domain prefixes. We don't reject unknown domains
* (the API accepts any matching the regex), but unknown ones get a
* warning so the operator sees what's standard. Add new domains here
* as integrations land.
*/
const KNOWN_DOMAINS = new Set([
'sensor', 'binary_sensor', 'switch', 'light', 'climate', 'cover',
'fan', 'media_player', 'lock', 'camera', 'vacuum', 'humidifier',
'water_heater', 'scene', 'script', 'automation', 'input_boolean',
'input_number', 'input_text', 'input_select', 'input_datetime',
'person', 'device_tracker', 'zone', 'sun', 'weather', 'calendar',
'remote', 'siren', 'select', 'number', 'text', 'button',
'homeassistant', 'homecore', 'group', 'notify', 'tts', 'alarm_control_panel',
]);
type FieldValidity = { ok: true } | { ok: false; level: 'err' | 'warn'; msg: string };
function validateEntityId(id: string): FieldValidity {
const trimmed = id.trim();
if (!trimmed) return { ok: false, level: 'err', msg: 'required' };
if (!ENTITY_ID_RE.test(trimmed)) {
return {
ok: false,
level: 'err',
msg: 'must match domain.snake_case (lowercase, digits, underscores)',
};
}
const domain = trimmed.split('.')[0]!;
if (!KNOWN_DOMAINS.has(domain)) {
return {
ok: false,
level: 'warn',
msg: `unknown domain "${domain}" — HA-standard domains include sensor / light / switch / binary_sensor / climate`,
};
}
return { ok: true };
}
function validateState(s: string): FieldValidity {
if (!s.trim()) return { ok: false, level: 'err', msg: 'required' };
return { ok: true };
}
function validateAttrs(raw: string): FieldValidity {
if (!raw.trim()) return { ok: true }; // empty = {}
try {
const parsed = JSON.parse(raw);
if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) {
return { ok: false, level: 'err', msg: 'must be a JSON object (not array, not scalar)' };
}
return { ok: true };
} catch (e) {
return { ok: false, level: 'err', msg: `JSON parse: ${e instanceof Error ? e.message : String(e)}` };
}
}
@customElement('hc-entity-form')
export class EntityForm extends LitElement {
@property({ type: String }) entityId = '';
@@ -31,6 +88,10 @@ export class EntityForm extends LitElement {
@state() private _attrs = '';
@state() private _err: string | null = null;
/** Per-field live validity. `null` = haven't typed yet (no decoration). */
@state() private _idValid: FieldValidity | null = null;
@state() private _stateValid: FieldValidity | null = null;
@state() private _attrsValid: FieldValidity | null = null;
static styles = css`
:host { display: block; font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif); color: var(--hc-text, #e6eaee); }
@@ -45,6 +106,14 @@ export class EntityForm extends LitElement {
}
input:focus, textarea:focus { outline: 2px solid hsl(185 80% 50% / 0.5); border-color: var(--hc-primary, #19d4e5); }
input[disabled] { opacity: 0.5; cursor: not-allowed; }
input.invalid, textarea.invalid { border-color: hsl(0 60% 50%); }
input.warn, textarea.warn { border-color: hsl(38 80% 55%); }
.field-status { font-size: 11px; margin-top: 4px; 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.warn { color: hsl(38 80% 65%); }
.field-status .sigil { display: inline-block; width: 12px; text-align: center; font-weight: 700; }
button.primary[disabled] { background: hsl(220 15% 20%); color: var(--hc-text-muted, #7b899d); border-color: var(--hc-border, #2a323e); cursor: not-allowed; }
textarea { min-height: 90px; resize: vertical; }
.hint { font-size: 11px; color: var(--hc-text-muted, #7b899d); margin-top: 4px; }
.err { margin-top: 10px; padding: 10px; border: 1px solid #b35a5a; border-radius: 6px; background: hsl(0 35% 12%); color: #f0c0c0; font-size: 12px; }
@@ -70,6 +139,47 @@ export class EntityForm extends LitElement {
}
}
/** Allow the host (Dashboard) to surface a server-side error inline. */
public setSubmitError(msg: string | null): void {
this._err = msg;
}
/** True iff every field is valid (warnings are OK, errors block). Public so the host can bind a disabled state on the submit button. */
public isValid(): boolean {
const checks = [
validateEntityId(this.entityId),
validateState(this.state),
validateAttrs(this._attrs),
];
return !checks.some((c) => !c.ok && c.level === 'err');
}
private _onIdInput(v: string) {
this.entityId = v;
this._idValid = validateEntityId(v);
}
private _onStateInput(v: string) {
this.state = v;
this._stateValid = validateState(v);
}
private _onAttrsInput(v: string) {
this._attrs = v;
this._attrsValid = validateAttrs(v);
}
private _statusLine(label: string, v: FieldValidity | null) {
if (v === null) return html``;
if (v.ok) return html`<div class="field-status ok"><span class="sigil">✓</span>${label} OK</div>`;
return html`<div class="field-status ${v.level}">
<span class="sigil">${v.level === 'warn' ? '!' : '✗'}</span>${v.msg}
</div>`;
}
private _fieldClass(v: FieldValidity | null): string {
if (v === null || v.ok) return '';
return v.level;
}
/** Public — call from host to trigger validation + emit submit event. */
public requestSubmit(): void { this._submit(); }
@@ -118,21 +228,27 @@ export class EntityForm extends LitElement {
<form @submit=${(e: Event) => { e.preventDefault(); this._submit(); }}>
<label for="eid">entity_id</label>
<input id="eid" .value=${this.entityId}
class=${this._fieldClass(this._idValid)}
?disabled=${this.editing}
@input=${(e: Event) => (this.entityId = (e.target as HTMLInputElement).value)}
@input=${(e: Event) => this._onIdInput((e.target as HTMLInputElement).value)}
placeholder="light.kitchen_ceiling" />
<div class="hint">format: <code>domain.snake_case</code> — domain like sensor / light / switch / binary_sensor</div>
${this._statusLine('entity_id', this._idValid)}
<label for="state">state</label>
<input id="state" .value=${this.state}
@input=${(e: Event) => (this.state = (e.target as HTMLInputElement).value)}
class=${this._fieldClass(this._stateValid)}
@input=${(e: Event) => this._onStateInput((e.target as HTMLInputElement).value)}
placeholder="on / off / 42 / 14.5 / detected" />
${this._statusLine('state', this._stateValid)}
<label for="attrs">attributes (JSON object)</label>
<textarea id="attrs" .value=${this._attrs}
@input=${(e: Event) => (this._attrs = (e.target as HTMLTextAreaElement).value)}
class=${this._fieldClass(this._attrsValid)}
@input=${(e: Event) => this._onAttrsInput((e.target as HTMLTextAreaElement).value)}
placeholder='{ "friendly_name": "Kitchen Ceiling", "brightness": 230 }'></textarea>
<div class="hint">optional; leave blank for <code>{}</code></div>
${this._statusLine('attributes', this._attrsValid)}
${this._err ? html`<div class="err">${this._err}</div>` : ''}
</form>
+11 -2
View File
@@ -173,6 +173,8 @@ export class Dashboard extends LitElement {
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',
@@ -182,14 +184,21 @@ export class Dashboard extends LitElement {
},
body: JSON.stringify({ state, attributes }),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
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.error = err instanceof Error ? err.message : String(err);
this._form?.setSubmitError(err instanceof Error ? err.message : String(err));
}
}
+194 -8
View File
@@ -1,13 +1,14 @@
/**
* Services page — lists every registered service grouped by domain.
* Reads from `/api/services` (HA-wire-compat).
* 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 { HomecoreClient } from '../api/client.js';
import type { ServiceDomainView } from '../api/types.js';
import '../components/Modal.js';
function resolveToken(): string {
if (typeof localStorage !== 'undefined') {
@@ -26,16 +27,93 @@ export class ServicesPage extends LitElement {
.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: 4px 10px; border-radius: 4px; font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); font-size: 12px; color: var(--hc-text-muted, #7b899d); }
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;
private client = new HomecoreClient({ token: resolveToken() });
@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();
@@ -53,7 +131,72 @@ export class ServicesPage extends LitElement {
} finally {
this.loading = false;
}
void this.client; // suppress unused warning while keeping the import shape consistent
}
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() {
@@ -69,16 +212,59 @@ export class ServicesPage extends LitElement {
</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>${name}</li>`)}
${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>
`;
}
}