@things-factory/integration-ui 10.0.0-beta.92 → 10.0.0-beta.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,347 @@
1
+ import '@material/web/button/filled-button.js'
2
+ import '@material/web/button/outlined-button.js'
3
+ import '@material/web/button/text-button.js'
4
+ import '@material/web/textfield/outlined-text-field.js'
5
+ import '@material/web/icon/icon.js'
6
+
7
+ import gql from 'graphql-tag'
8
+ import { LitElement, css, html } from 'lit'
9
+ import { customElement, property, state } from 'lit/decorators.js'
10
+ import { client } from '@operato/graphql'
11
+ import { notify } from '@operato/layout'
12
+ import { i18next, localize } from '@operato/i18n'
13
+
14
+ interface Resolution {
15
+ key: string
16
+ status: 'local' | 'inherited' | 'absent'
17
+ envVarId?: string
18
+ sourceDomainName?: string
19
+ value?: string
20
+ hasValue?: boolean
21
+ }
22
+
23
+ /**
24
+ * Connection / Step 파라미터 화면에서 useDomainAttribute 속성의 EnvVar 를
25
+ * 한 자리에서 확인·편집·삭제하는 인라인 편집기.
26
+ *
27
+ * 동작:
28
+ * - 현 도메인에 값이 있으면 update(M) / delete
29
+ * - 상속만 있는 경우(또는 없는 경우) "이 도메인에 등록" (create)
30
+ * - 부모 값을 덮어쓰면 closest-wins 로 자식 값이 적용됨을 안내
31
+ *
32
+ * 저장/삭제 성공 시 `saved` 이벤트 발생.
33
+ */
34
+ @customElement('env-var-quick-editor')
35
+ export class EnvVarQuickEditor extends localize(i18next)(LitElement) {
36
+ static styles = css`
37
+ :host {
38
+ display: flex;
39
+ flex-direction: column;
40
+ min-width: 460px;
41
+ max-height: 80vh;
42
+ padding: 20px 24px;
43
+ box-sizing: border-box;
44
+ background: var(--md-sys-color-surface, #fff);
45
+ }
46
+
47
+ h3 {
48
+ margin: 0 0 4px;
49
+ font-size: 16px;
50
+ flex: 0 0 auto;
51
+ }
52
+
53
+ /* 스크롤 가능 본문 — flex column 안에서 actions 위쪽 영역만 흘러내림 */
54
+ .body {
55
+ flex: 1 1 auto;
56
+ overflow: auto;
57
+ min-height: 0;
58
+ }
59
+
60
+ .key {
61
+ font-family: var(--md-sys-typescale-body-medium-font-family, monospace);
62
+ font-size: 12px;
63
+ color: var(--md-sys-color-on-surface-variant);
64
+ background: var(--md-sys-color-surface-variant);
65
+ padding: 4px 8px;
66
+ border-radius: 6px;
67
+ display: inline-block;
68
+ margin-bottom: 16px;
69
+ word-break: break-all;
70
+ }
71
+
72
+ .status {
73
+ display: flex;
74
+ align-items: center;
75
+ gap: 8px;
76
+ margin-bottom: 14px;
77
+ font-size: 14px;
78
+ }
79
+
80
+ .status[data-status='local'] {
81
+ color: var(--md-sys-color-tertiary);
82
+ }
83
+ .status[data-status='inherited'] {
84
+ color: var(--md-sys-color-primary);
85
+ }
86
+ .status[data-status='absent'] {
87
+ color: var(--md-sys-color-error);
88
+ }
89
+
90
+ md-outlined-text-field {
91
+ width: 100%;
92
+ margin-bottom: 12px;
93
+ }
94
+
95
+ .actions {
96
+ display: flex;
97
+ justify-content: flex-end;
98
+ gap: 8px;
99
+ flex: 0 0 auto;
100
+ padding-top: 12px;
101
+ margin-top: 4px;
102
+ border-top: 1px solid var(--md-sys-color-outline-variant, #e0e0e0);
103
+ }
104
+
105
+ .info {
106
+ font-size: 12px;
107
+ color: var(--md-sys-color-on-surface-variant);
108
+ margin-bottom: 12px;
109
+ line-height: 1.5;
110
+ }
111
+
112
+ .scope {
113
+ font-size: 12px;
114
+ line-height: 1.6;
115
+ padding: 10px 12px;
116
+ margin-bottom: 14px;
117
+ border-radius: 8px;
118
+ background: var(--md-sys-color-surface-container-low, #f5f5f5);
119
+ border-left: 3px solid var(--md-sys-color-primary, #3457d5);
120
+ }
121
+
122
+ .scope .scope-title {
123
+ font-weight: 600;
124
+ color: var(--md-sys-color-on-surface);
125
+ margin-bottom: 4px;
126
+ display: flex;
127
+ align-items: center;
128
+ gap: 4px;
129
+ }
130
+
131
+ .scope .scope-title md-icon {
132
+ font-size: 14px;
133
+ --md-icon-size: 14px;
134
+ }
135
+
136
+ .scope ul {
137
+ margin: 4px 0 0;
138
+ padding-left: 18px;
139
+ color: var(--md-sys-color-on-surface-variant);
140
+ }
141
+
142
+ .scope li {
143
+ margin-bottom: 2px;
144
+ }
145
+
146
+ .scope code {
147
+ font-family: var(--md-sys-typescale-body-medium-font-family, monospace);
148
+ font-size: 11px;
149
+ background: var(--md-sys-color-surface-variant);
150
+ padding: 1px 4px;
151
+ border-radius: 3px;
152
+ }
153
+ `
154
+
155
+ /** EnvVar 키 (예: `Connection::kiscon-conn::password`) */
156
+ @property({ type: String }) key_!: string
157
+
158
+ /** 속성 사양 (type/secret 여부 표시 등에 사용) */
159
+ @property({ type: Object }) propSpec: any
160
+
161
+ /** 사전 조회한 해소 상태 */
162
+ @property({ type: Object }) resolution!: Resolution
163
+
164
+ /** 표시용 라벨 (속성 이름) */
165
+ @property({ type: String }) propLabel: string = ''
166
+
167
+ @state() private editedValue: string = ''
168
+ @state() private busy: boolean = false
169
+
170
+ connectedCallback() {
171
+ super.connectedCallback()
172
+ this.editedValue = this.resolution?.value ?? ''
173
+ }
174
+
175
+ private get isSecret(): boolean {
176
+ return this.propSpec?.type === 'secret' || /password|secret|token|key$/i.test(this.propLabel || '')
177
+ }
178
+
179
+ /**
180
+ * 저장 시 영향 범위 (scope) 안내 패널 — 운영자 의사결정 보조.
181
+ * - absent : 처음 등록. 자손 트리 전체에 inherit.
182
+ * - local : 이미 등록됨. 자손이 inherit (자손에 자기 override 있으면 그것 우선).
183
+ * - inherited: 상위에서 받고있음. 여기 등록 시 이 도메인 + 자손에만 한정 override.
184
+ */
185
+ private _renderScopePanel(isLocal: boolean, isInherited: boolean) {
186
+ if (isLocal) {
187
+ return html`
188
+ <div class="scope">
189
+ <div class="scope-title">
190
+ <md-icon>info</md-icon>
191
+ 저장 위치 / 영향 범위
192
+ </div>
193
+ 이 도메인에 이미 등록된 값이 자손 도메인 전체에 inherit 됩니다 (closest-wins).
194
+ <ul>
195
+ <li>여기서 값을 바꾸면 → 이 도메인 + 자손 트리 전체에 즉시 반영</li>
196
+ <li>특정 자손에 자기 override 가 등록되어 있으면 그쪽은 자기 값 우선</li>
197
+ <li>"이 도메인에서 삭제" → 부모(있다면) 값으로 fallback</li>
198
+ </ul>
199
+ </div>
200
+ `
201
+ }
202
+ if (isInherited) {
203
+ return html`
204
+ <div class="scope">
205
+ <div class="scope-title">
206
+ <md-icon>inventory</md-icon>
207
+ 저장 위치 / 영향 범위
208
+ </div>
209
+ 현재 <code>${this.resolution.sourceDomainName || '상위 도메인'}</code> 에서 상속된 값을 사용 중입니다.
210
+ <ul>
211
+ <li>여기서 등록하면 → 이 도메인 + 그 자손에만 우선 적용 (다른 형제 도메인은 영향 없음)</li>
212
+ <li>나머지 다른 자손들은 여전히
213
+ <code>${this.resolution.sourceDomainName || '상위 도메인'}</code> 의 값을 그대로 사용</li>
214
+ </ul>
215
+ </div>
216
+ `
217
+ }
218
+ return html`
219
+ <div class="scope">
220
+ <div class="scope-title">
221
+ <md-icon>add_circle</md-icon>
222
+ 저장 위치 / 영향 범위
223
+ </div>
224
+ 이 도메인에 처음 등록합니다.
225
+ <ul>
226
+ <li>저장 후 → 이 도메인 + 자손 트리 전체에서 사용</li>
227
+ <li>특정 자손이 자기 값을 별도 등록하면 그쪽은 자기 값 우선 (closest-wins)</li>
228
+ <li>잘못된 도메인에 저장하지 않도록 — 현재 컨텍스트 도메인을 한번 확인하세요</li>
229
+ </ul>
230
+ </div>
231
+ `
232
+ }
233
+
234
+ private get statusLabel(): string {
235
+ const s = this.resolution?.status
236
+ if (s === 'local') return `✓ ${i18next.t('text.set-on-this-domain') || '이 도메인에 등록됨'}`
237
+ if (s === 'inherited')
238
+ return `🔗 ${i18next.t('text.inherited-from') || '상속'} (${this.resolution.sourceDomainName || '부모 도메인'})`
239
+ return `⚠ ${i18next.t('text.not-set') || '미설정'}`
240
+ }
241
+
242
+ render() {
243
+ const isLocal = this.resolution?.status === 'local'
244
+ const isInherited = this.resolution?.status === 'inherited'
245
+
246
+ return html`
247
+ <h3>${i18next.t('text.domain-attribute') || '도메인 속성'} · ${this.propLabel || this.key_}</h3>
248
+
249
+ <div class="body">
250
+ <div class="key">${this.key_}</div>
251
+
252
+ <div class="status" data-status=${this.resolution?.status || 'absent'}>${this.statusLabel}</div>
253
+
254
+ ${this._renderScopePanel(isLocal, isInherited)}
255
+
256
+ <md-outlined-text-field
257
+ label=${i18next.t('field.value')}
258
+ type=${this.isSecret ? 'password' : 'text'}
259
+ .value=${this.editedValue}
260
+ @input=${(e: any) => (this.editedValue = e.target.value)}
261
+ ?disabled=${this.busy}
262
+ ></md-outlined-text-field>
263
+ </div>
264
+
265
+ <div class="actions">
266
+ ${isLocal
267
+ ? html`<md-text-button @click=${this._delete} ?disabled=${this.busy}>
268
+ ${i18next.t('button.delete-from-this-domain') || '이 도메인에서 삭제'}
269
+ </md-text-button>`
270
+ : ''}
271
+ <md-outlined-button @click=${this._cancel} ?disabled=${this.busy}>
272
+ ${i18next.t('button.cancel')}
273
+ </md-outlined-button>
274
+ <md-filled-button @click=${this._save} ?disabled=${this.busy || !this.editedValue}>
275
+ ${isLocal ? i18next.t('button.update') || '갱신' : i18next.t('button.set') || '등록'}
276
+ </md-filled-button>
277
+ </div>
278
+ `
279
+ }
280
+
281
+ private _cancel() {
282
+ this.dispatchEvent(new CustomEvent('cancel', { bubbles: true, composed: true }))
283
+ }
284
+
285
+ private async _save() {
286
+ this.busy = true
287
+ try {
288
+ const isUpdate = this.resolution?.status === 'local' && this.resolution?.envVarId
289
+ const patches = isUpdate
290
+ ? [{ id: this.resolution.envVarId, cuFlag: 'M', value: this.editedValue, active: true }]
291
+ : [
292
+ {
293
+ cuFlag: '+',
294
+ name: this.key_,
295
+ value: this.editedValue,
296
+ active: true,
297
+ description: this.propLabel || ''
298
+ }
299
+ ]
300
+
301
+ const response = await client.mutate({
302
+ mutation: gql`
303
+ mutation ($patches: [EnvVarPatch!]!) {
304
+ updateMultipleEnvVars(patches: $patches) {
305
+ name
306
+ }
307
+ }
308
+ `,
309
+ variables: { patches }
310
+ })
311
+
312
+ if (response.errors) throw new Error(response.errors.map((e: any) => e.message).join('\n'))
313
+
314
+ notify({ message: i18next.t('text.saved-successfully') || '저장되었습니다' })
315
+ this.dispatchEvent(new CustomEvent('saved', { bubbles: true, composed: true }))
316
+ } catch (e: any) {
317
+ notify({ level: 'error', message: e.message || String(e) })
318
+ } finally {
319
+ this.busy = false
320
+ }
321
+ }
322
+
323
+ private async _delete() {
324
+ if (!this.resolution?.envVarId) return
325
+ // 도메인 환경변수 삭제는 확인 없이 즉시 처리.
326
+ this.busy = true
327
+ try {
328
+ const response = await client.mutate({
329
+ mutation: gql`
330
+ mutation ($id: String!) {
331
+ deleteEnvVar(id: $id)
332
+ }
333
+ `,
334
+ variables: { id: this.resolution.envVarId }
335
+ })
336
+
337
+ if (response.errors) throw new Error(response.errors.map((e: any) => e.message).join('\n'))
338
+
339
+ notify({ message: i18next.t('text.deleted-successfully') || '삭제되었습니다' })
340
+ this.dispatchEvent(new CustomEvent('saved', { bubbles: true, composed: true }))
341
+ } catch (e: any) {
342
+ notify({ level: 'error', message: e.message || String(e) })
343
+ } finally {
344
+ this.busy = false
345
+ }
346
+ }
347
+ }
@@ -45,8 +45,14 @@ export declare class Connection extends Connection_base {
45
45
  pageInitialized(): Promise<void>;
46
46
  fetchHandler({ page, limit, sortings, filters }: FetchOption): Promise<{
47
47
  total: any;
48
- records: any;
48
+ records: any[];
49
49
  }>;
50
+ /**
51
+ * 한 페이지 분 connections 에 대해 useDomainAttribute params 키를 모두 모아 단일
52
+ * envVarResolutions 호출로 해소한다. 그 후 각 connection 의 키 부분집합에 대해
53
+ * diagnoseReadiness 로 _readiness 를 주입.
54
+ */
55
+ _annotateReadiness(items: any[]): Promise<any[]>;
50
56
  fetchConnectors(): Promise<void>;
51
57
  _deleteConnections(name: any): Promise<void>;
52
58
  _updateConnectionManager(): Promise<void>;
@@ -13,44 +13,7 @@ import { PageView } from '@operato/shell';
13
13
  import { CommonButtonStyles, CommonGristStyles, ScrollbarStyles } from '@operato/styles';
14
14
  import { isMobileDevice } from '@operato/utils';
15
15
  import { p13n } from '@operato/p13n';
16
- async function copyToClipboard(text) {
17
- try {
18
- await navigator.clipboard.writeText(text);
19
- }
20
- catch (err) { // fallback: old way
21
- const textArea = document.createElement('textarea');
22
- textArea.value = text;
23
- document.body.appendChild(textArea);
24
- textArea.select();
25
- document.execCommand('copy');
26
- document.body.removeChild(textArea);
27
- }
28
- }
29
- function createActionInjector(connectionName) {
30
- return (propName, propSpec) => {
31
- if (!propSpec.useDomainAttribute) {
32
- return null;
33
- }
34
- const attributeName = `Connection::${connectionName}::${propName}`;
35
- const copyIcon = document.createElement('md-icon');
36
- copyIcon.textContent = 'dictionary';
37
- copyIcon.style.cssText =
38
- 'cursor: pointer; color: var(--md-sys-color-primary); font-size: 16px; --md-icon-size: 16px;';
39
- copyIcon.title = `Copy ${attributeName}`;
40
- copyIcon.addEventListener('click', () => {
41
- copyToClipboard(attributeName);
42
- // 복사 성공 피드백
43
- const originalText = copyIcon.textContent;
44
- copyIcon.textContent = 'check';
45
- copyIcon.style.color = 'var(--md-sys-color-tertiary)';
46
- setTimeout(() => {
47
- copyIcon.textContent = originalText;
48
- copyIcon.style.color = 'var(--md-sys-color-primary)';
49
- }, 1000);
50
- });
51
- return copyIcon;
52
- };
53
- }
16
+ import { createEnvVarActionInjector, diagnoseReadiness, resolveEnvVars } from '../viewparts/env-var-action-injector.js';
54
17
  let Connection = class Connection extends p13n(localize(i18next)(PageView)) {
55
18
  constructor() {
56
19
  super(...arguments);
@@ -196,6 +159,27 @@ let Connection = class Connection extends p13n(localize(i18next)(PageView)) {
196
159
  },
197
160
  width: 120
198
161
  },
162
+ {
163
+ type: 'select',
164
+ name: 'inheritanceMode',
165
+ label: true,
166
+ header: i18next.t('field.inheritance-mode') || '상속 모드',
167
+ record: {
168
+ editable: true,
169
+ // GraphQL enum 이름 (대문자) 을 value 로 사용 — type-graphql registerEnumType 이
170
+ // 노출하는 형식과 일치해야 함. 서버 내부 runtime 값('isolate'/'share') 으로의
171
+ // 변환은 GraphQL 레이어가 자동.
172
+ options: [
173
+ { display: '(connector 기본)', value: null },
174
+ { display: 'ISOLATE — 자식별 격리 인스턴스 (기본·안전)', value: 'ISOLATE' },
175
+ { display: 'SHARE — 자식이 부모 인스턴스 공유', value: 'SHARE' }
176
+ ]
177
+ },
178
+ // SHARE 의 의미는 select 옵션 라벨에 명시 ("자식이 부모 인스턴스 공유").
179
+ // 운영자가 의식적으로 선택. native confirm() 차단 다이얼로그 제거 — 잘못 선택해도
180
+ // 즉시 ISOLATE / (connector 기본) 으로 되돌릴 수 있음.
181
+ width: 200
182
+ },
199
183
  { type: 'connector',
200
184
  name: 'type',
201
185
  label: true,
@@ -224,18 +208,46 @@ let Connection = class Connection extends p13n(localize(i18next)(PageView)) {
224
208
  header: i18next.t('field.params'),
225
209
  record: { editable: true,
226
210
  options: async (value, column, record, row, field) => {
227
- const { name, help, parameterSpec: spec } = record.type ? this.connectors?.[record.type] : {};
211
+ // 동시에 운영자가 데이터 문제를 인지할 있도록 콘솔 경고를 남김.
212
+ let connectorEntry = null;
213
+ if (record.type) {
214
+ connectorEntry = this.connectors?.[record.type] || null;
215
+ if (!connectorEntry && this.connectors) {
216
+ console.warn(`[connection] connector type '${record.type}' (connection '${record.name}') is not registered. ` +
217
+ `Server-side package missing or not bootstrapped.`);
218
+ }
219
+ }
220
+ const { name, help, parameterSpec: spec } = connectorEntry || {};
228
221
  const context = this.grist;
222
+ // useDomainAttribute params 의 EnvVar 해소 상태 사전 조회
223
+ const domainAttrParams = (Array.isArray(spec) ? spec : []).filter((p) => p?.useDomainAttribute && p?.name);
224
+ const keys = domainAttrParams.map((p) => `Connection::${record.name}::${p.name}`);
225
+ const resolutions = await resolveEnvVars(keys);
229
226
  return { name,
230
227
  help,
231
228
  spec,
232
229
  context,
233
230
  objectified: true,
234
- actionInjector: createActionInjector(record.name)
231
+ actionInjector: createEnvVarActionInjector((propName) => `Connection::${record.name}::${propName}`, resolutions, () => this.grist.fetch())
235
232
  };
236
233
  },
237
234
  renderer: 'json5' },
238
235
  width: 100 },
236
+ {
237
+ type: 'string',
238
+ name: '_readiness',
239
+ header: i18next.t('field.domain-attribute-readiness') || '준비 상태',
240
+ record: {
241
+ editable: false,
242
+ renderer: (_v, _c, r) => {
243
+ const d = r?._readiness;
244
+ if (!d)
245
+ return '';
246
+ return d.label;
247
+ }
248
+ },
249
+ width: 180
250
+ },
239
251
  { type: 'resource-object',
240
252
  name: 'edge',
241
253
  header: i18next.t('field.edge-server'),
@@ -287,6 +299,7 @@ let Connection = class Connection extends p13n(localize(i18next)(PageView)) {
287
299
  endpoint
288
300
  active
289
301
  onDemand
302
+ inheritanceMode
290
303
  state
291
304
  params
292
305
  updater { id
@@ -304,10 +317,40 @@ let Connection = class Connection extends p13n(localize(i18next)(PageView)) {
304
317
  sortings
305
318
  }
306
319
  });
320
+ const items = response.data.responses.items || [];
321
+ const records = await this._annotateReadiness(items);
307
322
  return { total: response.data.responses.total || 0,
308
- records: response.data.responses.items || []
323
+ records
309
324
  };
310
325
  }
326
+ /**
327
+ * 한 페이지 분 connections 에 대해 useDomainAttribute params 키를 모두 모아 단일
328
+ * envVarResolutions 호출로 해소한다. 그 후 각 connection 의 키 부분집합에 대해
329
+ * diagnoseReadiness 로 _readiness 를 주입.
330
+ */
331
+ async _annotateReadiness(items) {
332
+ if (!items || items.length === 0)
333
+ return items;
334
+ if (!this.connectors) { // connectors 로딩 전 호출되었으면 일단 그대로
335
+ return items;
336
+ }
337
+ const perConn = [];
338
+ const allKeys = [];
339
+ for (const conn of items) {
340
+ const connector = this.connectors[conn.type];
341
+ const spec = Array.isArray(connector?.parameterSpec) ? connector.parameterSpec : [];
342
+ const keys = spec
343
+ .filter((p) => p?.useDomainAttribute && p?.name)
344
+ .map((p) => `Connection::${conn.name}::${p.name}`);
345
+ perConn.push({ conn, keys });
346
+ allKeys.push(...keys);
347
+ }
348
+ const uniqueKeys = Array.from(new Set(allKeys));
349
+ const resolutions = await resolveEnvVars(uniqueKeys);
350
+ return perConn.map(({ conn, keys }) => ({ ...conn,
351
+ _readiness: diagnoseReadiness(resolutions, keys)
352
+ }));
353
+ }
311
354
  async fetchConnectors() {
312
355
  const response = await client.query({ query: gql `
313
356
  query { connectors { items { name
@@ -330,6 +373,10 @@ let Connection = class Connection extends p13n(localize(i18next)(PageView)) {
330
373
  connectors[connector.name] = connector;
331
374
  return connectors;
332
375
  }, {});
376
+ // connectors 가 준비된 시점에 readiness 가 비어있다면 한 번 더 fetch
377
+ if (this.grist?.dirtyData?.records?.some?.((r) => !r._readiness)) {
378
+ this.grist.fetch();
379
+ }
333
380
  }
334
381
  else {
335
382
  console.error('fetch connectors error');