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')
}
)
}
}