Skip to content

Commit 99c78f5

Browse files
committed
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>
1 parent 3f5a741 commit 99c78f5

1 file changed

Lines changed: 194 additions & 8 deletions

File tree

frontend/src/pages/Services.ts

Lines changed: 194 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
/**
2-
* Services page — lists every registered service grouped by domain.
3-
* Reads from `/api/services` (HA-wire-compat).
2+
* Services page — lists every registered service grouped by domain,
3+
* and lets the operator call any of them with a JSON service_data
4+
* payload (POST /api/services/<domain>/<service>).
45
*/
56

67
import { LitElement, html, css } from 'lit';
78
import { customElement, state } from 'lit/decorators.js';
89

9-
import { HomecoreClient } from '../api/client.js';
1010
import type { ServiceDomainView } from '../api/types.js';
11+
import '../components/Modal.js';
1112

1213
function resolveToken(): string {
1314
if (typeof localStorage !== 'undefined') {
@@ -26,16 +27,93 @@ export class ServicesPage extends LitElement {
2627
.domain { background: hsl(220 20% 10%); border: 1px solid var(--hc-border, #2a323e); border-radius: 8px; margin-bottom: 12px; padding: 14px 16px; }
2728
.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); }
2829
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+
li {
31+
background: hsl(220 25% 14%);
32+
padding: 0;
33+
border-radius: 4px;
34+
font-family: var(--hc-font-mono, 'JetBrains Mono', monospace);
35+
font-size: 12px;
36+
color: var(--hc-text-muted, #7b899d);
37+
display: inline-flex;
38+
align-items: center;
39+
}
40+
li .name { padding: 4px 10px; }
41+
li button.call {
42+
background: hsl(220 25% 18%);
43+
color: var(--hc-primary, #19d4e5);
44+
border: none;
45+
border-left: 1px solid var(--hc-border, #2a323e);
46+
padding: 4px 10px;
47+
font-size: 11px;
48+
cursor: pointer;
49+
font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif);
50+
font-weight: 600;
51+
border-radius: 0 4px 4px 0;
52+
}
53+
li button.call:hover { background: var(--hc-primary, #19d4e5); color: var(--hc-primary-fg, #0b0e13); }
3054
.empty { padding: 24px; border: 1px dashed var(--hc-border, #2a323e); border-radius: 8px; text-align: center; color: var(--hc-text-muted, #7b899d); }
3155
.err { padding: 16px; border: 1px dashed #b35a5a; border-radius: 8px; color: #f0c0c0; font-size: 13px; }
56+
.toast { padding: 8px 12px; background: hsl(165 60% 16%); color: hsl(165 60% 80%); border-radius: 6px; font-size: 12px; margin-bottom: 12px; }
57+
58+
/* Service-call modal contents */
59+
.form label { display: block; margin: 6px 0 4px; font-size: 12px; color: var(--hc-text-muted, #7b899d); }
60+
.form code.target { color: var(--hc-primary, #19d4e5); font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); font-size: 13px; }
61+
.form textarea {
62+
width: 100%; box-sizing: border-box;
63+
padding: 8px 10px; background: hsl(220 25% 10%);
64+
border: 1px solid var(--hc-border, #2a323e); border-radius: 6px;
65+
color: var(--hc-text, #e6eaee);
66+
font-family: var(--hc-font-mono, 'JetBrains Mono', monospace);
67+
font-size: 13px;
68+
min-height: 90px;
69+
resize: vertical;
70+
}
71+
.form textarea.invalid { border-color: hsl(0 60% 50%); }
72+
.form .hint { font-size: 11px; color: var(--hc-text-muted, #7b899d); margin-top: 4px; }
73+
.form .field-status { font-size: 11px; margin-top: 4px; }
74+
.form .field-status.ok { color: hsl(150 60% 55%); }
75+
.form .field-status.err { color: hsl(0 70% 70%); }
76+
.form pre {
77+
background: hsl(220 25% 8%);
78+
border: 1px solid var(--hc-border, #2a323e);
79+
border-radius: 6px;
80+
padding: 12px;
81+
font-family: var(--hc-font-mono, 'JetBrains Mono', monospace);
82+
font-size: 12px;
83+
white-space: pre-wrap;
84+
word-break: break-word;
85+
max-height: 240px;
86+
overflow-y: auto;
87+
margin-top: 8px;
88+
}
89+
.form .resp-ok { border-color: hsl(150 50% 35%); }
90+
.form .resp-err { border-color: hsl(0 50% 45%); color: #f0c0c0; }
91+
.form .err { padding: 10px; margin-top: 10px; border: 1px solid #b35a5a; border-radius: 6px; background: hsl(0 35% 12%); color: #f0c0c0; font-size: 12px; }
92+
93+
button.btn {
94+
padding: 8px 16px;
95+
background: hsl(220 25% 14%);
96+
color: var(--hc-text, #e6eaee);
97+
border: 1px solid var(--hc-border, #2a323e);
98+
border-radius: 6px;
99+
font-size: 13px;
100+
cursor: pointer;
101+
font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif);
102+
}
103+
button.btn:hover { background: hsl(220 20% 18%); }
104+
button.btn.primary { background: var(--hc-primary, #19d4e5); color: var(--hc-primary-fg, #0b0e13); border-color: var(--hc-primary, #19d4e5); font-weight: 600; }
105+
button.btn.primary[disabled] { background: hsl(220 15% 20%); color: var(--hc-text-muted, #7b899d); border-color: var(--hc-border, #2a323e); cursor: not-allowed; }
32106
`;
33107

34108
@state() private domains: ServiceDomainView[] = [];
35109
@state() private error: string | null = null;
36110
@state() private loading = true;
37-
38-
private client = new HomecoreClient({ token: resolveToken() });
111+
@state() private calling: { domain: string; service: string } | null = null;
112+
@state() private callBody = '{}';
113+
@state() private callResp: { ok: boolean; text: string } | null = null;
114+
@state() private callErr: string | null = null;
115+
@state() private callPending = false;
116+
@state() private callToast: string | null = null;
39117

40118
connectedCallback(): void {
41119
super.connectedCallback();
@@ -53,7 +131,72 @@ export class ServicesPage extends LitElement {
53131
} finally {
54132
this.loading = false;
55133
}
56-
void this.client; // suppress unused warning while keeping the import shape consistent
134+
}
135+
136+
private _openCall(domain: string, service: string) {
137+
this.calling = { domain, service };
138+
this.callBody = '{}';
139+
this.callResp = null;
140+
this.callErr = null;
141+
}
142+
143+
private _closeCall() {
144+
this.calling = null;
145+
this.callBody = '{}';
146+
this.callResp = null;
147+
this.callErr = null;
148+
this.callPending = false;
149+
}
150+
151+
private _validateBody(): { ok: boolean; data?: unknown; msg?: string } {
152+
const raw = this.callBody.trim();
153+
if (!raw) return { ok: true, data: {} };
154+
try {
155+
const parsed = JSON.parse(raw);
156+
if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) {
157+
return { ok: false, msg: 'service_data must be a JSON object (not array, not scalar)' };
158+
}
159+
return { ok: true, data: parsed };
160+
} catch (e) {
161+
return { ok: false, msg: `JSON parse: ${e instanceof Error ? e.message : String(e)}` };
162+
}
163+
}
164+
165+
private async _doCall() {
166+
if (!this.calling) return;
167+
const v = this._validateBody();
168+
if (!v.ok) {
169+
this.callErr = v.msg ?? 'invalid';
170+
this.callResp = null;
171+
return;
172+
}
173+
this.callPending = true;
174+
this.callErr = null;
175+
const { domain, service } = this.calling;
176+
try {
177+
const r = await fetch(`/api/services/${encodeURIComponent(domain)}/${encodeURIComponent(service)}`, {
178+
method: 'POST',
179+
headers: {
180+
'Authorization': `Bearer ${resolveToken()}`,
181+
'Content-Type': 'application/json',
182+
},
183+
body: JSON.stringify(v.data ?? {}),
184+
});
185+
const text = await r.text();
186+
if (r.ok) {
187+
let pretty = text;
188+
try { pretty = JSON.stringify(JSON.parse(text), null, 2); } catch { /* leave raw */ }
189+
this.callResp = { ok: true, text: pretty };
190+
this.callToast = `Called ${domain}.${service} → 200`;
191+
window.setTimeout(() => (this.callToast = null), 3000);
192+
} else {
193+
this.callResp = { ok: false, text: `HTTP ${r.status}\n${text}` };
194+
}
195+
} catch (e) {
196+
this.callErr = e instanceof Error ? e.message : String(e);
197+
} finally {
198+
this.callPending = false;
199+
}
57200
}
58201

59202
render() {
@@ -69,16 +212,59 @@ export class ServicesPage extends LitElement {
69212
</div>
70213
`;
71214
}
215+
const validity = this._validateBody();
72216
return html`
217+
${this.callToast ? html`<div class="toast">${this.callToast}</div>` : ''}
73218
<h1>Services (${this.domains.length} domain${this.domains.length === 1 ? '' : 's'})</h1>
74219
${this.domains.map(d => html`
75220
<div class="domain">
76221
<h2>${d.domain}</h2>
77222
<ul>
78-
${Object.keys(d.services).map(name => html`<li>${name}</li>`)}
223+
${Object.keys(d.services).map(name => html`
224+
<li>
225+
<span class="name">${name}</span>
226+
<button class="call"
227+
@click=${() => this._openCall(d.domain, name)}
228+
title="Call ${d.domain}.${name}">▶ Call</button>
229+
</li>
230+
`)}
79231
</ul>
80232
</div>
81233
`)}
234+
235+
<hc-modal .open=${this.calling !== null}
236+
heading=${this.calling ? `Call ${this.calling.domain}.${this.calling.service}` : ''}
237+
@hc-modal-close=${this._closeCall}>
238+
<div class="form">
239+
<label>target</label>
240+
<div><code class="target">POST /api/services/${this.calling?.domain ?? ''}/${this.calling?.service ?? ''}</code></div>
241+
242+
<label for="body">service_data (JSON object)</label>
243+
<textarea id="body"
244+
class=${validity.ok ? '' : 'invalid'}
245+
.value=${this.callBody}
246+
@input=${(e: Event) => (this.callBody = (e.target as HTMLTextAreaElement).value)}
247+
placeholder='{ "entity_id": "light.kitchen_ceiling", "brightness": 200 }'></textarea>
248+
<div class="hint">leave blank for <code>{}</code> — these handlers are no-op echoes, they round-trip whatever you send</div>
249+
${validity.ok
250+
? (this.callBody.trim()
251+
? html`<div class="field-status ok">✓ service_data OK</div>`
252+
: html`<div class="hint">empty → will send <code>{}</code></div>`)
253+
: html`<div class="field-status err">${validity.msg}</div>`}
254+
255+
${this.callErr ? html`<div class="err">${this.callErr}</div>` : ''}
256+
${this.callResp
257+
? html`<label>response</label>
258+
<pre class=${this.callResp.ok ? 'resp-ok' : 'resp-err'}>${this.callResp.text}</pre>`
259+
: ''}
260+
</div>
261+
<button slot="footer" class="btn" @click=${this._closeCall}>Close</button>
262+
<button slot="footer" class="btn primary"
263+
?disabled=${!validity.ok || this.callPending}
264+
@click=${this._doCall}>
265+
${this.callPending ? 'Calling…' : 'Call'}
266+
</button>
267+
</hc-modal>
82268
`;
83269
}
84270
}

0 commit comments

Comments
 (0)