From 99c78f512c7ccc68164544c050ec98bcbaac8822 Mon Sep 17 00:00:00 2001 From: ruv Date: Tue, 26 May 2026 15:27:48 -0400 Subject: [PATCH] feat(homecore-ui iter 5): Call Service from Services page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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// and inspect the round-tripped response. Modal contents: - heading "Call ." - target URL displayed as code (POST /api/services/...) - service_data JSON textarea (default `{}`, live-validated as JSON object — same rules as EntityForm.attributes) - response
 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 `` 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 
---
 frontend/src/pages/Services.ts | 202 +++++++++++++++++++++++++++++++--
 1 file changed, 194 insertions(+), 8 deletions(-)

diff --git a/frontend/src/pages/Services.ts b/frontend/src/pages/Services.ts
index 045e6649..4016bee4 100644
--- a/frontend/src/pages/Services.ts
+++ b/frontend/src/pages/Services.ts
@@ -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//).
  */
 
 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 {
                 
             `;
         }
+        const validity = this._validateBody();
         return html`
+            ${this.callToast ? html`
${this.callToast}
` : ''}

Services (${this.domains.length} domain${this.domains.length === 1 ? '' : 's'})

${this.domains.map(d => html`

${d.domain}

    - ${Object.keys(d.services).map(name => html`
  • ${name}
  • `)} + ${Object.keys(d.services).map(name => html` +
  • + ${name} + +
  • + `)}
`)} + + +
+ +
POST /api/services/${this.calling?.domain ?? ''}/${this.calling?.service ?? ''}
+ + + +
leave blank for {} — these handlers are no-op echoes, they round-trip whatever you send
+ ${validity.ok + ? (this.callBody.trim() + ? html`
✓ service_data OK
` + : html`
empty → will send {}
`) + : html`
✗ ${validity.msg}
`} + + ${this.callErr ? html`
${this.callErr}
` : ''} + ${this.callResp + ? html` +
${this.callResp.text}
` + : ''} +
+ + +
`; } }