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
67import { LitElement , html , css } from 'lit' ;
78import { customElement , state } from 'lit/decorators.js' ;
89
9- import { HomecoreClient } from '../api/client.js' ;
1010import type { ServiceDomainView } from '../api/types.js' ;
11+ import '../components/Modal.js' ;
1112
1213function 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