@things-factory/operato-twin 10.0.0-zeta.10

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.
Files changed (41) hide show
  1. package/_index.html +15 -0
  2. package/client/bootstrap.ts +26 -0
  3. package/client/index.ts +0 -0
  4. package/client/pages/twin-overview-page.ts +260 -0
  5. package/client/route.ts +12 -0
  6. package/client/tsconfig.json +12 -0
  7. package/config/config.development.js +15 -0
  8. package/config/config.production.js +12 -0
  9. package/dist-client/bootstrap.d.ts +2 -0
  10. package/dist-client/bootstrap.js +19 -0
  11. package/dist-client/bootstrap.js.map +1 -0
  12. package/dist-client/index.d.ts +0 -0
  13. package/dist-client/index.js +2 -0
  14. package/dist-client/index.js.map +1 -0
  15. package/dist-client/pages/twin-overview-page.d.ts +22 -0
  16. package/dist-client/pages/twin-overview-page.js +273 -0
  17. package/dist-client/pages/twin-overview-page.js.map +1 -0
  18. package/dist-client/route.d.ts +1 -0
  19. package/dist-client/route.js +13 -0
  20. package/dist-client/route.js.map +1 -0
  21. package/dist-client/tsconfig.tsbuildinfo +1 -0
  22. package/dist-server/board/demo-wms.d.ts +3 -0
  23. package/dist-server/board/demo-wms.js +33 -0
  24. package/dist-server/board/demo-wms.js.map +1 -0
  25. package/dist-server/bootstrap-twin.d.ts +1 -0
  26. package/dist-server/bootstrap-twin.js +34 -0
  27. package/dist-server/bootstrap-twin.js.map +1 -0
  28. package/dist-server/index.d.ts +2 -0
  29. package/dist-server/index.js +7 -0
  30. package/dist-server/index.js.map +1 -0
  31. package/dist-server/middlewares/index.d.ts +2 -0
  32. package/dist-server/middlewares/index.js +5 -0
  33. package/dist-server/middlewares/index.js.map +1 -0
  34. package/dist-server/tsconfig.tsbuildinfo +1 -0
  35. package/package.json +55 -0
  36. package/server/board/demo-wms.ts +34 -0
  37. package/server/bootstrap-twin.ts +38 -0
  38. package/server/index.ts +4 -0
  39. package/server/middlewares/index.ts +3 -0
  40. package/server/tsconfig.json +9 -0
  41. package/things-factory.config.js +8 -0
package/_index.html ADDED
@@ -0,0 +1,15 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="google" content="notranslate" />
6
+ <title>Operato Twin</title>
7
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
8
+ <meta name="description" content="Headless digital-twin host" />
9
+ <base href="/" />
10
+ <meta name="theme-color" content="#4d9de0" />
11
+ </head>
12
+ <body>
13
+ <things-app appTitle="Operato Twin"></things-app>
14
+ </body>
15
+ </html>
@@ -0,0 +1,26 @@
1
+ import '@operato/i18n/ox-i18n.js'
2
+
3
+ import { setAuthManagementMenus } from '@things-factory/auth-ui/dist-client'
4
+ import { setupAppToolPart } from '@things-factory/apptool-ui/dist-client'
5
+ import { setupContextUIPart } from '@things-factory/context-ui/dist-client'
6
+
7
+ console.log(
8
+ '%c Operato Twin — headless digital-twin host (walking skeleton) ',
9
+ 'background: #222; color: #4d9de0'
10
+ )
11
+
12
+ export default async function bootstrap() {
13
+ await setupAppToolPart({
14
+ toolbar: true,
15
+ busybar: true,
16
+ mdibar: false
17
+ })
18
+
19
+ await setupContextUIPart({
20
+ titlebar: 'header',
21
+ contextToolbar: 'page-footer'
22
+ })
23
+
24
+ /* set auth management menus into more-panel */
25
+ setAuthManagementMenus()
26
+ }
File without changes
@@ -0,0 +1,260 @@
1
+ import { css, html } from 'lit'
2
+ import { customElement, state } from 'lit/decorators.js'
3
+ import gql from 'graphql-tag'
4
+
5
+ import { PageView } from '@operato/shell'
6
+ import { client, subscribe } from '@operato/graphql'
7
+
8
+ /*
9
+ * Twin Overview — 첫 실 페이지(Overview + 트랜잭션 원장).
10
+ * 헤드리스 트윈의 3채널/저널을 소비: twinInstances(목록) · twinReplay(현재 상태 재구성) ·
11
+ * twinEvents(최근 트랜잭션) · twinState 구독(라이브 델타).
12
+ * 설계 SoT: operato-twin/design/ui/00-ui-design.md (§3.1 Overview + §3.3 Ledger).
13
+ */
14
+ @customElement('twin-overview-page')
15
+ export class TwinOverviewPage extends PageView {
16
+ static styles = css`
17
+ :host {
18
+ display: block;
19
+ --bg: #eef1f3;
20
+ --panel: #ffffff;
21
+ --line: #d5dce0;
22
+ --line-soft: #e6eaed;
23
+ --ink: #17242c;
24
+ --muted: #57676f;
25
+ --faint: #8a959c;
26
+ --accent: #0b8f9c;
27
+ --accent-wash: #0b8f9c16;
28
+ --good: #1a8f4c;
29
+ --warn: #b9741f;
30
+ --mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace;
31
+ background: var(--bg);
32
+ color: var(--ink);
33
+ min-height: 100%;
34
+ overflow: auto;
35
+ font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
36
+ }
37
+ @media (prefers-color-scheme: dark) {
38
+ :host {
39
+ --bg: #0d1216; --panel: #141b21; --line: #26323a; --line-soft: #1c2630;
40
+ --ink: #dde7ec; --muted: #8b9aa3; --faint: #5c6b73; --accent: #22c3cf; --accent-wash: #22c3cf1e;
41
+ --good: #35c46e; --warn: #e0a24a;
42
+ }
43
+ }
44
+ .wrap { padding: 20px; max-width: 1200px; margin: 0 auto; }
45
+ .head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 4px; }
46
+ h2 { margin: 0; font-size: 20px; font-weight: 660; letter-spacing: -.01em; }
47
+ .sub { color: var(--muted); font-size: 13px; margin: 2px 0 0; }
48
+ .lbl { font-family: var(--mono); font-size: 10.5px; letter-spacing: .09em; text-transform: uppercase; color: var(--muted); }
49
+ .pill { display: inline-flex; align-items: center; gap: 6px; font-family: var(--mono); font-size: 11.5px; font-weight: 600;
50
+ padding: 3px 10px; border-radius: 20px; border: 1px solid var(--line); color: var(--muted); }
51
+ .pill .g { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); }
52
+ .pill.live { color: var(--good); border-color: color-mix(in oklab, var(--good), transparent 60%); }
53
+ .pill.live .g { background: var(--good); animation: pulse 1.8s infinite; }
54
+ @keyframes pulse { 0% { box-shadow: 0 0 0 0 color-mix(in oklab, var(--good), transparent 40%); } 70% { box-shadow: 0 0 0 6px transparent; } 100% { box-shadow: 0 0 0 0 transparent; } }
55
+ @media (prefers-reduced-motion: reduce) { .pill.live .g { animation: none; } }
56
+
57
+ .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin: 16px 0; }
58
+ .tile { background: var(--panel); border: 1px solid var(--line); border-radius: 9px; padding: 14px; }
59
+ .tile .val { font-family: var(--mono); font-size: 26px; font-weight: 600; letter-spacing: -.02em; margin-top: 6px; }
60
+ .tile .val .u { font-size: 13px; color: var(--faint); font-weight: 500; margin-left: 3px; }
61
+
62
+ .cols { display: grid; gap: 14px; }
63
+ @media (min-width: 860px) { .cols { grid-template-columns: 1fr 1.5fr; } }
64
+ .card { background: var(--panel); border: 1px solid var(--line); border-radius: 9px; padding: 15px; }
65
+ .card h3 { margin: 0 0 10px; font-size: 13.5px; font-weight: 640; display: flex; justify-content: space-between; align-items: center; }
66
+
67
+ .nodes { display: flex; flex-direction: column; gap: 9px; }
68
+ .node { display: grid; grid-template-columns: 90px 1fr 54px; align-items: center; gap: 10px; font-size: 12.5px; }
69
+ .node .nm { font-family: var(--mono); color: var(--ink); }
70
+ .bar { height: 8px; border-radius: 5px; background: var(--line); overflow: hidden; }
71
+ .bar > i { display: block; height: 100%; background: var(--accent); border-radius: 5px; transition: width .4s; }
72
+ .node .oc { font-family: var(--mono); text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; }
73
+
74
+ .tablewrap { overflow-x: auto; border: 1px solid var(--line); border-radius: 8px; }
75
+ table { border-collapse: collapse; width: 100%; font-size: 12.5px; }
76
+ thead th { text-align: left; font-family: var(--mono); font-size: 10px; letter-spacing: .06em; text-transform: uppercase;
77
+ color: var(--muted); font-weight: 600; padding: 9px 12px; background: color-mix(in oklab, var(--panel), var(--bg) 55%);
78
+ border-bottom: 1px solid var(--line); white-space: nowrap; }
79
+ tbody td { padding: 8px 12px; border-bottom: 1px solid var(--line-soft); white-space: nowrap; }
80
+ tbody tr:last-child td { border-bottom: 0; }
81
+ td.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
82
+ .type { font-family: var(--mono); font-size: 11px; color: var(--accent); }
83
+ .empty { color: var(--faint); font-size: 13px; padding: 20px; text-align: center; }
84
+ .foot { color: var(--faint); font-size: 11px; margin-top: 14px; }
85
+ .foot code { font-family: var(--mono); }
86
+ `
87
+
88
+ @state() private instanceId = ''
89
+ @state() private snap: any = null
90
+ @state() private events: any[] = []
91
+ @state() private live = false
92
+ private sub?: { unsubscribe: () => void }
93
+ private timer?: any
94
+
95
+ get context() {
96
+ return { title: 'Twin Overview' }
97
+ }
98
+
99
+ async firstUpdated() {
100
+ await this.pickInstance()
101
+ if (!this.instanceId) return
102
+ await this.refresh()
103
+ await this.startLive()
104
+ this.timer = setInterval(() => this.refresh(), 3000)
105
+ }
106
+
107
+ disconnectedCallback() {
108
+ super.disconnectedCallback()
109
+ this.sub?.unsubscribe()
110
+ clearInterval(this.timer)
111
+ }
112
+
113
+ private async pickInstance() {
114
+ try {
115
+ const res = await client.query({ query: gql`query { twinInstances }` })
116
+ this.instanceId = res.data?.twinInstances?.[0] ?? ''
117
+ } catch (e) {
118
+ /* not signed in / no instance — leave empty */
119
+ }
120
+ }
121
+
122
+ private async refresh() {
123
+ if (!this.instanceId) return
124
+ try {
125
+ const [rep, evs] = await Promise.all([
126
+ client.query({
127
+ query: gql`query ($id: String!) { twinReplay(instanceId: $id) }`,
128
+ variables: { id: this.instanceId },
129
+ fetchPolicy: 'no-cache'
130
+ }),
131
+ client.query({
132
+ query: gql`query ($id: String!) { twinEvents(instanceId: $id, limit: 25) { revision eventType eventTime payload } }`,
133
+ variables: { id: this.instanceId },
134
+ fetchPolicy: 'no-cache'
135
+ })
136
+ ])
137
+ this.snap = rep.data?.twinReplay ?? this.snap
138
+ this.events = evs.data?.twinEvents ?? []
139
+ } catch (e) {
140
+ /* transient — keep last */
141
+ }
142
+ }
143
+
144
+ private async startLive() {
145
+ try {
146
+ this.sub = await subscribe(
147
+ {
148
+ query: gql`
149
+ subscription ($id: String!) {
150
+ twinState(instanceId: $id) {
151
+ instanceId
152
+ kind
153
+ revision
154
+ }
155
+ }
156
+ `,
157
+ variables: { id: this.instanceId }
158
+ },
159
+ {
160
+ next: () => {
161
+ this.live = true
162
+ /* 델타 도착 → 다음 refresh 주기가 상태·원장 갱신(희소 스트림이라 3s 폴백으로 충분). */
163
+ }
164
+ }
165
+ )
166
+ } catch (e) {
167
+ /* subscription unavailable — polling still refreshes */
168
+ }
169
+ }
170
+
171
+ /* ── derive ledger detail from EPCIS/operational payload ── */
172
+ private detail(ev: any): string {
173
+ const p = ev?.payload
174
+ const d = p?.data
175
+ if (!d) return ''
176
+ if (typeof ev.eventType === 'string' && ev.eventType.startsWith('epcis.')) {
177
+ const step = (d.bizStep || '').split(':').pop() || ''
178
+ const epc = (d.epcList?.[0] || d.parentID || (d.quantityList?.[0]?.epcClass ?? '')) as string
179
+ const tail = epc ? epc.split(/[:.]/).pop() : ''
180
+ return [d.type, step, tail].filter(Boolean).join(' · ')
181
+ }
182
+ return JSON.stringify(d).slice(0, 60)
183
+ }
184
+ private timeOf(ev: any): string {
185
+ const t = ev?.eventTime || ''
186
+ return typeof t === 'string' && t.length >= 19 ? t.slice(11, 19) : t
187
+ }
188
+ private shortType(t: string): string {
189
+ return (t || '').replace('epcis.', '').replace('.status', '·status')
190
+ }
191
+
192
+ render() {
193
+ if (!this.instanceId) {
194
+ return html`<div class="wrap"><h2>Twin Overview</h2>
195
+ <p class="empty">실행 중인 트윈 인스턴스가 없거나 인증이 필요합니다. 서버 기동(<code>serve:dev</code>) 후 admin 으로 로그인하세요.</p></div>`
196
+ }
197
+ const s = this.snap
198
+ const nodes = s?.nodes ?? []
199
+ const occ = nodes.reduce((a: number, n: any) => a + (n.occupancy || 0), 0)
200
+ const cap = nodes.reduce((a: number, n: any) => a + (n.capacity || 0), 0)
201
+ const movers = s?.movers ?? []
202
+ const moving = movers.filter((m: any) => m.status === 'moving' || m.motion).length
203
+
204
+ return html`
205
+ <div class="wrap">
206
+ <div class="head">
207
+ <h2>Twin Overview</h2>
208
+ <span class="pill ${this.live ? 'live' : ''}"><span class="g"></span>${this.live ? 'live' : 'polling'}</span>
209
+ <span class="pill"><span class="lbl" style="letter-spacing:.04em">instance</span>&nbsp;${this.instanceId}</span>
210
+ <span class="pill">rev ${s?.revision ?? '—'}</span>
211
+ </div>
212
+ <p class="sub">헤드리스 트윈의 현재 상태(저널 재구성)와 최근 트랜잭션.</p>
213
+
214
+ <div class="tiles">
215
+ <div class="tile"><span class="lbl">occupancy</span><div class="val">${occ}<span class="u">/ ${cap}</span></div></div>
216
+ <div class="tile"><span class="lbl">items</span><div class="val">${(s?.items ?? []).length}</div></div>
217
+ <div class="tile"><span class="lbl">orders</span><div class="val">${(s?.orders ?? []).length}</div></div>
218
+ <div class="tile"><span class="lbl">movers</span><div class="val">${moving}<span class="u">/ ${movers.length} moving</span></div></div>
219
+ <div class="tile"><span class="lbl">tasks</span><div class="val">${(s?.tasks ?? []).length}</div></div>
220
+ </div>
221
+
222
+ <div class="cols">
223
+ <div class="card">
224
+ <h3>Node occupancy <span class="lbl">live</span></h3>
225
+ <div class="nodes">
226
+ ${nodes.length === 0 ? html`<div class="empty">no nodes</div>` : nodes.map((n: any) => {
227
+ const pct = n.capacity ? Math.min(100, (n.occupancy / n.capacity) * 100) : 0
228
+ return html`<div class="node">
229
+ <span class="nm">${n.id}</span>
230
+ <span class="bar"><i style="width:${pct}%"></i></span>
231
+ <span class="oc">${n.occupancy}/${n.capacity}</span>
232
+ </div>`
233
+ })}
234
+ </div>
235
+ </div>
236
+
237
+ <div class="card">
238
+ <h3>Recent transactions <span class="lbl">event journal · newest first</span></h3>
239
+ <div class="tablewrap">
240
+ <table>
241
+ <thead><tr><th>time</th><th>rev</th><th>type</th><th>detail</th></tr></thead>
242
+ <tbody>
243
+ ${this.events.length === 0 ? html`<tr><td colspan="4" class="empty">no events yet</td></tr>` :
244
+ this.events.map(ev => html`<tr>
245
+ <td class="mono">${this.timeOf(ev)}</td>
246
+ <td class="mono">${ev.revision}</td>
247
+ <td><span class="type">${this.shortType(ev.eventType)}</span></td>
248
+ <td>${this.detail(ev)}</td>
249
+ </tr>`)}
250
+ </tbody>
251
+ </table>
252
+ </div>
253
+ </div>
254
+ </div>
255
+
256
+ <p class="foot">소비 API: <code>twinInstances</code> · <code>twinReplay</code>(DB→replay) · <code>twinEvents</code> · <code>twinState</code> 구독. 설계: design/ui/00-ui-design.md</p>
257
+ </div>
258
+ `
259
+ }
260
+ }
@@ -0,0 +1,12 @@
1
+ export default function route(page: string): string | undefined {
2
+ switch (page) {
3
+ case '':
4
+ /* 랜딩 리다이렉트(leading slash) — edge 패턴. 요소 resolve 는 아래 케이스에서. */
5
+ return '/twin-overview-page'
6
+ case 'twin-overview-page':
7
+ import('./pages/twin-overview-page')
8
+ return 'twin-overview-page'
9
+ default:
10
+ return undefined
11
+ }
12
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "../../tsconfig-base.json",
3
+ "compilerOptions": {
4
+ "experimentalDecorators": true,
5
+ "strict": true,
6
+ "declaration": true,
7
+ "module": "esnext",
8
+ "outDir": "../dist-client",
9
+ "baseUrl": "./"
10
+ },
11
+ "include": ["./**/*"]
12
+ }
@@ -0,0 +1,15 @@
1
+ module.exports = {
2
+ subdomain: 'system',
3
+ /* port 는 shell 기본값 3000 을 그대로 사용(오버라이드 안 함 → 표준 통일). */
4
+ accessTokenCookieKey: 'access_token.twin',
5
+ publicHomeRoute: '/auth/signin',
6
+ /* dev: 최초 1회 `run migration` 으로 스키마(synchronize) + 시드(SeedDomain SYSTEM/systemFlag + SeedUser admin) 적용.
7
+ * migration bin 이 sqlite+synchronize:false 에서 FK-off→synchronize→FK-on→runMigrations 순으로 처리. */
8
+ ormconfig: {
9
+ name: 'default',
10
+ type: 'sqlite',
11
+ database: 'db.sqlite',
12
+ synchronize: false,
13
+ logging: false
14
+ }
15
+ }
@@ -0,0 +1,12 @@
1
+ module.exports = {
2
+ subdomain: 'system',
3
+ accessTokenCookieKey: 'access_token.twin',
4
+ publicHomeRoute: '/auth/signin',
5
+ ormconfig: {
6
+ name: 'default',
7
+ type: 'sqlite',
8
+ database: 'db.sqlite',
9
+ synchronize: false,
10
+ logging: false
11
+ }
12
+ }
@@ -0,0 +1,2 @@
1
+ import '@operato/i18n/ox-i18n.js';
2
+ export default function bootstrap(): Promise<void>;
@@ -0,0 +1,19 @@
1
+ import '@operato/i18n/ox-i18n.js';
2
+ import { setAuthManagementMenus } from '@things-factory/auth-ui/dist-client';
3
+ import { setupAppToolPart } from '@things-factory/apptool-ui/dist-client';
4
+ import { setupContextUIPart } from '@things-factory/context-ui/dist-client';
5
+ console.log('%c Operato Twin — headless digital-twin host (walking skeleton) ', 'background: #222; color: #4d9de0');
6
+ export default async function bootstrap() {
7
+ await setupAppToolPart({
8
+ toolbar: true,
9
+ busybar: true,
10
+ mdibar: false
11
+ });
12
+ await setupContextUIPart({
13
+ titlebar: 'header',
14
+ contextToolbar: 'page-footer'
15
+ });
16
+ /* set auth management menus into more-panel */
17
+ setAuthManagementMenus();
18
+ }
19
+ //# sourceMappingURL=bootstrap.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bootstrap.js","sourceRoot":"","sources":["../client/bootstrap.ts"],"names":[],"mappings":"AAAA,OAAO,0BAA0B,CAAA;AAEjC,OAAO,EAAE,sBAAsB,EAAE,MAAM,qCAAqC,CAAA;AAC5E,OAAO,EAAE,gBAAgB,EAAE,MAAM,wCAAwC,CAAA;AACzE,OAAO,EAAE,kBAAkB,EAAE,MAAM,wCAAwC,CAAA;AAE3E,OAAO,CAAC,GAAG,CACT,kEAAkE,EAClE,kCAAkC,CACnC,CAAA;AAED,MAAM,CAAC,OAAO,CAAC,KAAK,UAAU,SAAS;IACrC,MAAM,gBAAgB,CAAC;QACrB,OAAO,EAAE,IAAI;QACb,OAAO,EAAE,IAAI;QACb,MAAM,EAAE,KAAK;KACd,CAAC,CAAA;IAEF,MAAM,kBAAkB,CAAC;QACvB,QAAQ,EAAE,QAAQ;QAClB,cAAc,EAAE,aAAa;KAC9B,CAAC,CAAA;IAEF,+CAA+C;IAC/C,sBAAsB,EAAE,CAAA;AAC1B,CAAC","sourcesContent":["import '@operato/i18n/ox-i18n.js'\n\nimport { setAuthManagementMenus } from '@things-factory/auth-ui/dist-client'\nimport { setupAppToolPart } from '@things-factory/apptool-ui/dist-client'\nimport { setupContextUIPart } from '@things-factory/context-ui/dist-client'\n\nconsole.log(\n '%c Operato Twin — headless digital-twin host (walking skeleton) ',\n 'background: #222; color: #4d9de0'\n)\n\nexport default async function bootstrap() {\n await setupAppToolPart({\n toolbar: true,\n busybar: true,\n mdibar: false\n })\n\n await setupContextUIPart({\n titlebar: 'header',\n contextToolbar: 'page-footer'\n })\n\n /* set auth management menus into more-panel */\n setAuthManagementMenus()\n}\n"]}
File without changes
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../client/index.ts"],"names":[],"mappings":"","sourcesContent":[""]}
@@ -0,0 +1,22 @@
1
+ import { PageView } from '@operato/shell';
2
+ export declare class TwinOverviewPage extends PageView {
3
+ static styles: import("lit").CSSResult;
4
+ private instanceId;
5
+ private snap;
6
+ private events;
7
+ private live;
8
+ private sub?;
9
+ private timer?;
10
+ get context(): {
11
+ title: string;
12
+ };
13
+ firstUpdated(): Promise<void>;
14
+ disconnectedCallback(): void;
15
+ private pickInstance;
16
+ private refresh;
17
+ private startLive;
18
+ private detail;
19
+ private timeOf;
20
+ private shortType;
21
+ render(): import("lit-html").TemplateResult<1>;
22
+ }