import '@operato/data-grist/ox-grist.js' import './connection-importer.js' import gql from 'graphql-tag' import { css, html } from 'lit' import { customElement, property, query } from 'lit/decorators.js' import { DataGrist } from '@operato/data-grist/ox-grist.js' import { client } from '@operato/graphql' import { HelpDecoratedRenderer } from '@operato/help/help-decorated-renderer.js' import { notify, openPopup } from '@operato/layout' import { i18next, localize } from '@operato/i18n' import { PageView } from '@operato/shell' import { CommonButtonStyles, CommonGristStyles, ScrollbarStyles } from '@operato/styles' import { isMobileDevice } from '@operato/utils' import { FetchOption } from '@operato/data-grist' import { p13n } from '@operato/p13n' import { createEnvVarActionInjector, diagnoseReadiness, resolveEnvVars } from '../viewparts/env-var-action-injector.js' @customElement('connection-page') export class Connection extends p13n(localize(i18next)(PageView)) { static styles = [ CommonGristStyles, ScrollbarStyles, css` :host { display: flex; flex-direction: column; overflow: hidden; } ox-grist { overflow-y: auto; flex: 1; } ` ] @property({ type: Boolean }) active: boolean = false @property({ type: Object }) gristConfig: any @property({ type: Object }) connectors: any @query('ox-grist') grist!: DataGrist get context() { return { title: i18next.t('text.connection list'), search: { handler: search => { this.grist.searchText = search }, value: this.grist?.searchText || '' }, // 필터가 설정되면, 아래 코멘트 해제 // filter: { // handler: () => { // const display = this.headroom.style.display // this.headroom.style.display = display !== 'none' ? 'none' : 'flex' // } // }, help: 'integration/ui/connection', actions: [ { title: i18next.t('button.save'), action: this._updateConnectionManager.bind(this), ...CommonButtonStyles.save }, { title: i18next.t('button.delete'), action: this._deleteConnections.bind(this), ...CommonButtonStyles.delete } ], exportable: { name: i18next.t('text.connection list'), data: this.exportHandler.bind(this) }, importable: { handler: this.importHandler.bind(this) } } } render() { return html` ` } async pageInitialized() { this.fetchConnectors() this.gristConfig = { list: { fields: ['name', 'description', 'type', 'active'] }, columns: [ { type: 'gutter', gutterName: 'sequence' }, { type: 'gutter', gutterName: 'row-selector', multiple: true }, { type: 'gutter', gutterName: 'button', name: 'state', icon: record => (!record ? 'link' : !record.id ? '' : record.state == 'CONNECTED' ? 'link_off' : 'link'), iconOnly: false, title: record => !record ? i18next.t('button.connect') : !record.id ? '' : record.state == 'CONNECTED' ? i18next.t('button.disconnect') : i18next.t('button.connect'), width: 80, handlers: { click: (columns, data, column, record, rowIndex) => { if (!record || !record.name || record.__dirty__ == '+') { return } if (record.state == 'CONNECTED') { this.disconnect(record) } else { this.connect(record) } } } }, { type: 'object', name: 'domain', hidden: true }, { type: 'string', name: 'name', label: true, header: i18next.t('field.name'), record: { editable: true, mandatory: true }, filter: 'search', sortable: true, width: 150, validation: function (after, before, record, column) { /* connected 상태에서는 이름을 바꿀 수 없다. */ if (record.state == 'CONNECTED') { notify({ level: 'warn', message: 'connection name cannot be changed during connected.' }) return false } return true } }, { type: 'string', name: 'description', label: true, header: i18next.t('field.description'), record: { editable: true }, filter: 'search', width: 200 }, { type: 'checkbox', name: 'active', label: true, header: i18next.t('field.startup-connect'), record: { editable: true, align: 'center' }, sortable: true, width: 60 }, { type: 'checkbox', name: 'onDemand', label: true, header: i18next.t('field.on-demand'), record: { editable: true }, width: 120 }, { type: 'select', name: 'inheritanceMode', label: true, header: i18next.t('field.inheritance-mode') || '상속 모드', record: { editable: true, // GraphQL enum 이름 (대문자) 을 value 로 사용 — type-graphql registerEnumType 이 // 노출하는 형식과 일치해야 함. 서버 내부 runtime 값('isolate'/'share') 으로의 // 변환은 GraphQL 레이어가 자동. options: [ { display: '(connector 기본)', value: null }, { display: 'ISOLATE — 자식별 격리 인스턴스 (기본·안전)', value: 'ISOLATE' }, { display: 'SHARE — 자식이 부모 인스턴스 공유', value: 'SHARE' } ] }, // SHARE 의 의미는 select 옵션 라벨에 명시 ("자식이 부모 인스턴스 공유"). // 운영자가 의식적으로 선택. native confirm() 차단 다이얼로그 제거 — 잘못 선택해도 // 즉시 ISOLATE / (connector 기본) 으로 되돌릴 수 있음. width: 200 }, { type: 'connector', name: 'type', label: true, header: i18next.t('field.type'), record: { renderer: HelpDecoratedRenderer, editable: true, help: value => this.connectors?.[value]?.help, mandatory: true }, filter: 'search', sortable: true, width: 200 }, { type: 'string', name: 'endpoint', header: i18next.t('field.endpoint'), record: { editable: true, mandatory: true }, filter: 'search', sortable: true, width: 280 }, { type: 'parameters', name: 'params', header: i18next.t('field.params'), record: { editable: true, options: async (value, column, record, row, field) => { // 등록된 connector 가 아닌 type 을 가진 Connection 에서 디스트럭처 죽음을 막고, // 동시에 운영자가 데이터 측 문제를 인지할 수 있도록 콘솔 경고를 남김. let connectorEntry: any = null if (record.type) { connectorEntry = this.connectors?.[record.type] || null if (!connectorEntry && this.connectors) { console.warn( `[connection] connector type '${record.type}' (connection '${record.name}') is not registered. ` + `Server-side package missing or not bootstrapped.` ) } } const { name, help, parameterSpec: spec } = connectorEntry || ({} as any) const context = this.grist // useDomainAttribute params 의 EnvVar 해소 상태 사전 조회 const domainAttrParams = (Array.isArray(spec) ? spec : []).filter((p: any) => p?.useDomainAttribute && p?.name) const keys = domainAttrParams.map((p: any) => `Connection::${record.name}::${p.name}`) const resolutions = await resolveEnvVars(keys) return { name, help, spec, context, objectified: true, actionInjector: createEnvVarActionInjector( (propName: string) => `Connection::${record.name}::${propName}`, resolutions, () => this.grist.fetch() ) } }, renderer: 'json5' }, width: 100 }, { type: 'string', name: '_readiness', header: i18next.t('field.domain-attribute-readiness') || '준비 상태', record: { editable: false, renderer: (_v: any, _c: any, r: any) => { const d = r?._readiness if (!d) return '' return d.label } }, width: 180 }, { type: 'resource-object', name: 'edge', header: i18next.t('field.edge-server'), record: { editable: true, options: { queryName: 'edges' } }, sortable: true, width: 120 }, { type: 'resource-object', name: 'updater', header: i18next.t('field.updater'), record: { editable: false }, sortable: true, width: 120 }, { type: 'datetime', name: 'updatedAt', header: i18next.t('field.updated_at'), record: { editable: false }, sortable: true, width: 180 } ], rows: { selectable: { multiple: true } }, sorters: [ { name: 'name' } ] } } async fetchHandler({ page, limit, sortings = [], filters = [] }: FetchOption) { const response = await client.query({ query: gql` query ($filters: [Filter!], $pagination: Pagination, $sortings: [Sorting!]) { responses: connections(filters: $filters, pagination: $pagination, sortings: $sortings) { items { id domain { id name description } name description type edge { id name } endpoint active onDemand inheritanceMode state params updater { id name description } updatedAt } total } } `, variables: { filters, pagination: { page, limit }, sortings } }) const items = response.data.responses.items || [] const records = await this._annotateReadiness(items) return { total: response.data.responses.total || 0, records } } /** * 한 페이지 분 connections 에 대해 useDomainAttribute params 키를 모두 모아 단일 * envVarResolutions 호출로 해소한다. 그 후 각 connection 의 키 부분집합에 대해 * diagnoseReadiness 로 _readiness 를 주입. */ async _annotateReadiness(items: any[]): Promise { if (!items || items.length === 0) return items if (!this.connectors) { // connectors 로딩 전 호출되었으면 일단 그대로 return items } type ConnKeys = { conn: any; keys: string[] } const perConn: ConnKeys[] = [] const allKeys: string[] = [] for (const conn of items) { const connector = this.connectors[conn.type] const spec = Array.isArray(connector?.parameterSpec) ? connector.parameterSpec : [] const keys = spec .filter((p: any) => p?.useDomainAttribute && p?.name) .map((p: any) => `Connection::${conn.name}::${p.name}`) perConn.push({ conn, keys }) allKeys.push(...keys) } const uniqueKeys = Array.from(new Set(allKeys)) const resolutions = await resolveEnvVars(uniqueKeys) return perConn.map(({ conn, keys }) => ({ ...conn, _readiness: diagnoseReadiness(resolutions, keys) })) } async fetchConnectors() { const response = await client.query({ query: gql` query { connectors { items { name help parameterSpec { type name label placeholder property styles useDomainAttribute } } } } ` }) if (!response.errors) { this.connectors = response.data.connectors.items.reduce((connectors, connector) => { connectors[connector.name] = connector return connectors }, {}) // connectors 가 준비된 시점에 readiness 가 비어있다면 한 번 더 fetch if (this.grist?.dirtyData?.records?.some?.((r: any) => !r._readiness)) { this.grist.fetch() } } else { console.error('fetch connectors error') } } async _deleteConnections(name) { if ( confirm( i18next.t('text.sure_to_x', { x: i18next.t('text.delete') }) ) ) { const names = this.grist.selected.map(record => record.name) if (names && names.length > 0) { const response = await client.mutate({ mutation: gql` mutation ($names: [String!]!) { deleteConnections(names: $names) } `, variables: { names } }) if (!response.errors) { this.grist.fetch() notify({ message: i18next.t('text.info_x_successfully', { x: i18next.t('text.delete') }) }) } } } } async _updateConnectionManager() { var patches = this.grist.dirtyRecords if (patches && patches.length) { patches = patches.map(connection => { let patchField: any = connection.id ? { id: connection.id } : {} const dirtyFields = connection.__dirtyfields__ for (let key in dirtyFields) { patchField[key] = dirtyFields[key].after } patchField.cuFlag = connection.__dirty__ return patchField }) const response = await client.mutate({ mutation: gql` mutation ($patches: [ConnectionPatch!]!) { updateMultipleConnection(patches: $patches) { name } } `, variables: { patches } }) if (!response.errors) this.grist.fetch() } } async connect(record) { var response = await client.mutate({ mutation: gql` mutation ($name: String!) { connectConnection(name: $name) { state } } `, variables: { name: record.name } }) var state = response.data.connectConnection.state record.state = state this.grist.refresh() notify({ level: 'info', message: `${state == 'CONNECTED' ? 'success' : 'fail'} to connect : ${record.name}` }) } async disconnect(record) { var response = await client.mutate({ mutation: gql` mutation ($name: String!) { disconnectConnection(name: $name) { state } } `, variables: { name: record.name } }) var state = response.data.disconnectConnection.state record.state = state this.grist.refresh() notify({ level: 'info', message: `${state == 'CONNECTED' ? 'fail' : 'success'} to disconnect : ${record.name}` }) } async exportHandler() { const exportTargets = this.grist.selected.length ? this.grist.selected : this.grist.dirtyData.records const targetFieldSet = new Set(['id', 'name', 'type', 'description', 'endpoint', 'params']) return exportTargets.map(connection => { let tempObj = {} for (const field of targetFieldSet) { tempObj[field] = connection[field] } return tempObj }) } async importHandler(records) { openPopup( html` { history.back() this.grist.fetch() }} > `, { backdrop: true, size: 'large', title: i18next.t('title.import connection') } ) } }