mega-framework 0.1.9 → 0.1.11
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.
- package/README.md +14 -4
- package/package.json +23 -21
- package/sample/crud/.env +10 -2
- package/sample/crud/.env.example +8 -0
- package/sample/crud/apps/main/controllers/bus-controller.js +122 -0
- package/sample/crud/apps/main/controllers/jobs-controller.js +22 -2
- package/sample/crud/apps/main/controllers/lock-controller.js +117 -0
- package/sample/crud/apps/main/jobs/email-job.js +37 -2
- package/sample/crud/apps/main/locales/server/en.json +36 -1
- package/sample/crud/apps/main/locales/server/ko.json +36 -1
- package/sample/crud/apps/main/public/js/bus-demo.js +131 -0
- package/sample/crud/apps/main/public/js/lock-demo.js +122 -0
- package/sample/crud/apps/main/routes/bus.js +43 -0
- package/sample/crud/apps/main/routes/lock.js +35 -0
- package/sample/crud/apps/main/services/jobs-demo-service.js +22 -15
- package/sample/crud/apps/main/views/bus/index.ejs +80 -0
- package/sample/crud/apps/main/views/jobs/index.ejs +9 -3
- package/sample/crud/apps/main/views/layouts/main.ejs +2 -0
- package/sample/crud/apps/main/views/lock/index.ejs +99 -0
- package/sample/crud/docs/guide/03-service-model-db.md +48 -0
- package/sample/crud/docs/guide/05-scheduler-job-worker.md +29 -2
- package/sample/crud/docs/guide/09-distributed-lock-and-bus.md +224 -0
- package/sample/crud/docs/guide/10-multi-app.md +74 -0
- package/sample/crud/mega.config.js +32 -0
- package/sample/crud/package.json +3 -2
- package/sample/multi/.env +16 -0
- package/sample/multi/.env.example +17 -0
- package/sample/multi/README.md +54 -0
- package/sample/multi/apps/admin/app.config.js +24 -0
- package/sample/multi/apps/admin/controllers/admin-controller.js +42 -0
- package/sample/multi/apps/admin/public/js/admin.js +31 -0
- package/sample/multi/apps/admin/routes/pages.js +11 -0
- package/sample/multi/apps/admin/views/index.ejs +33 -0
- package/sample/multi/apps/web/app.config.js +30 -0
- package/sample/multi/apps/web/controllers/web-controller.js +45 -0
- package/sample/multi/apps/web/public/js/web.js +24 -0
- package/sample/multi/apps/web/routes/pages.js +13 -0
- package/sample/multi/apps/web/views/index.ejs +51 -0
- package/sample/multi/mega.config.js +42 -0
- package/sample/multi/package.json +20 -0
- package/sample/simple/package.json +2 -2
- package/src/adapters/nats-adapter.js +39 -44
- package/src/adapters/nats-codec.js +38 -0
- package/src/cli/commands/scaffold.js +1 -0
- package/src/cli/index.js +50 -1
- package/src/core/app-registry.js +69 -0
- package/src/core/boot.js +99 -0
- package/src/core/bus/cluster-bus.js +190 -0
- package/src/core/bus/contract.js +123 -0
- package/src/core/bus/index.js +285 -0
- package/src/core/bus/memory-bus.js +103 -0
- package/src/core/bus/nats-bus.js +203 -0
- package/src/core/config-validator.js +118 -1
- package/src/core/ctx-builder.js +14 -1
- package/src/core/index.js +2 -0
- package/src/core/lock/cluster-lock.js +174 -0
- package/src/core/lock/contract.js +123 -0
- package/src/core/lock/fifo-waitlist.js +93 -0
- package/src/core/lock/index.js +292 -0
- package/src/core/lock/memory-lock.js +162 -0
- package/src/core/lock/redis-lock.js +276 -0
- package/src/core/mega-app.js +29 -0
- package/src/core/migration/generate.js +1 -1
- package/src/core/migration/journal.js +1 -1
- package/src/core/scope-registry.js +9 -0
- package/src/eslint-plugin/no-direct-model-import.js +2 -2
- package/src/index.js +2 -0
- package/src/lib/mega-job-queue.js +71 -47
- package/types/adapters/mega-adapter.d.ts +1 -1
- package/types/adapters/nats-adapter.d.ts +4 -4
- package/types/adapters/nats-codec.d.ts +13 -0
- package/types/adapters/redlock-adapter.d.ts +1 -1
- package/types/core/app-registry.d.ts +22 -0
- package/types/core/bus/cluster-bus.d.ts +45 -0
- package/types/core/bus/contract.d.ts +164 -0
- package/types/core/bus/index.d.ts +100 -0
- package/types/core/bus/memory-bus.d.ts +45 -0
- package/types/core/bus/nats-bus.d.ts +41 -0
- package/types/core/index.d.ts +1 -0
- package/types/core/lock/cluster-lock.d.ts +44 -0
- package/types/core/lock/contract.d.ts +181 -0
- package/types/core/lock/fifo-waitlist.d.ts +38 -0
- package/types/core/lock/index.d.ts +96 -0
- package/types/core/lock/memory-lock.d.ts +58 -0
- package/types/core/lock/redis-lock.d.ts +43 -0
- package/types/core/mega-app.d.ts +10 -0
- package/types/core/scope-registry.d.ts +6 -0
- package/types/index.d.ts +1 -1
- package/types/lib/mega-job-queue.d.ts +27 -4
- package/sample/simple/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
|
@@ -251,12 +251,16 @@
|
|
|
251
251
|
"jobs_reload": "새로고침",
|
|
252
252
|
"jobs_field_to": "받는 사람",
|
|
253
253
|
"jobs_field_mode": "모드",
|
|
254
|
+
"jobs_field_delay": "지연(ms) — hang 모드 전용",
|
|
255
|
+
"jobs_field_delay_hint": "비워두면 기본 8000ms. 5000ms(timeoutMs) 초과 시 timeout 으로 DLQ 격리됩니다.",
|
|
254
256
|
"jobs_mode_ok": "성공",
|
|
255
257
|
"jobs_mode_flaky": "재시도",
|
|
256
258
|
"jobs_mode_fail": "영구 실패",
|
|
259
|
+
"jobs_mode_hang": "타임아웃",
|
|
257
260
|
"jobs_mode_ok_hint": "첫 시도에 바로 성공",
|
|
258
261
|
"jobs_mode_flaky_hint": "1번째 시도 실패 → 재시도 → 2번째 성공",
|
|
259
262
|
"jobs_mode_fail_hint": "매 시도 실패 → 재시도 소진 후 DLQ 격리",
|
|
263
|
+
"jobs_mode_hang_hint": "delayMs 만큼 지연 → 5초(timeoutMs) 초과 시 timeout → DLQ",
|
|
260
264
|
"jobs_dlq_title": "DLQ (격리된 잡)",
|
|
261
265
|
"jobs_dlq_desc": "재시도를 모두 소진한 영구 실패 잡이 모이는 곳입니다(NATS 스트림). 원인 분석·재처리용.",
|
|
262
266
|
"jobs_dlq_empty": "아직 DLQ 로 간 잡이 없습니다.",
|
|
@@ -274,6 +278,7 @@
|
|
|
274
278
|
"jobs_status_sent": "발송됨",
|
|
275
279
|
"jobs_status_retry": "재시도",
|
|
276
280
|
"jobs_status_failed": "실패",
|
|
281
|
+
"jobs_status_running": "처리 중",
|
|
277
282
|
"jobs_notice_enqueued": "잡을 큐에 넣었습니다. 워커가 처리하면 아래 이벤트에 나타납니다.",
|
|
278
283
|
"worker_title": "CPU 워커 데모 (MegaWorker)",
|
|
279
284
|
"worker_subtitle": "SHA-256 N회 반복 같은 CPU-bound 작업을 worker_threads 풀에서 돌립니다. 계산 도중에도 서버가 다른 요청에 즉시 응답하는지(메인 스레드 non-block) 하트비트로 확인합니다.",
|
|
@@ -324,5 +329,35 @@
|
|
|
324
329
|
"field_phone": "전화번호",
|
|
325
330
|
"field_phone_ph": "예: 010-1234-5678",
|
|
326
331
|
"col_username": "아이디",
|
|
327
|
-
"col_nickname": "별명"
|
|
332
|
+
"col_nickname": "별명",
|
|
333
|
+
"nav_lock": "Distributed Lock",
|
|
334
|
+
"nav_bus": "Message Bus",
|
|
335
|
+
"lock_title": "분산 락 데모",
|
|
336
|
+
"lock_subtitle": "ctx.lock.with / tryAcquire 로 임계구역을 보호합니다. 두 탭에서 같은 키로 동시에 실행하면 한 번에 하나만 들어가고(상호배제), FIFO 면 도착 순서대로 깨어납니다.",
|
|
337
|
+
"lock_run_title": "임계구역 실행",
|
|
338
|
+
"lock_run_desc": "키를 잡고 holdMs 동안 점유한 뒤 자동 해제합니다(ctx.lock.with). 다른 시도는 waitMs 까지 대기합니다.",
|
|
339
|
+
"lock_f_key": "자원 키",
|
|
340
|
+
"lock_f_fifo": "(도착 순서 보장)",
|
|
341
|
+
"lock_f_fence": "(단조 토큰)",
|
|
342
|
+
"lock_f_ext": "(watchdog 자동연장)",
|
|
343
|
+
"lock_btn_run": "실행 (with)",
|
|
344
|
+
"lock_btn_try": "tryAcquire",
|
|
345
|
+
"lock_status_title": "현재 상태",
|
|
346
|
+
"lock_active": "보유 중",
|
|
347
|
+
"lock_waiting": "대기 중",
|
|
348
|
+
"lock_worker_pid": "이 페이지를 처리한 워커 PID",
|
|
349
|
+
"lock_log_title": "최근 실행 (이 워커)",
|
|
350
|
+
"lock_log_result": "결과",
|
|
351
|
+
"bus_title": "메시지 버스 데모",
|
|
352
|
+
"bus_subtitle": "ctx.bus.emit / on / request 로 이벤트를 주고받습니다. 이 페이지는 demo.> 를 구독해 수신 이벤트를 실시간(폴링) 표시합니다 — 다른 워커가 발행해도 fan-out 으로 도착합니다.",
|
|
353
|
+
"bus_emit_title": "발행 (emit)",
|
|
354
|
+
"bus_emit_desc": "subject 와 payload(JSON)를 fan-out 발행합니다. persist:true 면 JetStream 에 저장됩니다.",
|
|
355
|
+
"bus_btn_emit": "Emit",
|
|
356
|
+
"bus_req_title": "요청/응답 (request)",
|
|
357
|
+
"bus_req_desc": "demo.echo 응답자가 첫 응답을 돌려줍니다 — 어느 워커가 답했는지 PID 로 보입니다.",
|
|
358
|
+
"bus_btn_request": "Request demo.echo",
|
|
359
|
+
"bus_recv_title": "수신 이벤트",
|
|
360
|
+
"bus_recv_pid": "구독 워커",
|
|
361
|
+
"bus_recv_desc": "서버가 demo.> 를 구독합니다 — * 는 한 토큰, > 는 꼬리 전체. order.created / order.created.eu / user.login 모두 demo.> 에 매칭됩니다. persist 이벤트는 배지로 구분됩니다.",
|
|
362
|
+
"bus_recv_pid_col": "수신 워커"
|
|
328
363
|
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/*
|
|
3
|
+
* /demo/bus 클라이언트 — 메시지 버스 데모(ADR-227).
|
|
4
|
+
*
|
|
5
|
+
* - 발행/요청: 버튼이 /demo/bus/emit · /demo/bus/request 에 JSON POST(폼 아님 → CSRF 면제 + Origin 검증).
|
|
6
|
+
* - 수신: 2초마다 /demo/bus/events 를 폴링해 이 워커가 demo.> 로 받은 이벤트(subject·persist·수신 PID·payload)를
|
|
7
|
+
* 보여준다(탭 숨김 시 멈춤). 폴링 간격은 앱 rate limit(100/분, ADR-073) 안에 둔다.
|
|
8
|
+
*/
|
|
9
|
+
;(function () {
|
|
10
|
+
var $ = function (/** @type {string} */ id) {
|
|
11
|
+
return /** @type {any} */ (document.getElementById(id))
|
|
12
|
+
}
|
|
13
|
+
function esc(/** @type {any} */ s) {
|
|
14
|
+
return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) {
|
|
15
|
+
return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
function parseJson(/** @type {string} */ text, /** @type {any} */ fallback) {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(text)
|
|
21
|
+
} catch {
|
|
22
|
+
return fallback
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function postJson(/** @type {string} */ url, /** @type {any} */ body) {
|
|
26
|
+
return fetch(url, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
29
|
+
body: JSON.stringify(body),
|
|
30
|
+
}).then(function (res) {
|
|
31
|
+
return res.json().then(function (j) {
|
|
32
|
+
if (!res.ok) throw new Error((j && j.error && j.error.code) || 'http ' + res.status)
|
|
33
|
+
return j.data != null ? j.data : j
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── presets ──────────────────────────────────────────────────────────────
|
|
39
|
+
var presets = document.querySelectorAll('.bus-preset')
|
|
40
|
+
for (var i = 0; i < presets.length; i++) {
|
|
41
|
+
presets[i].addEventListener('click', function (/** @type {any} */ ev) {
|
|
42
|
+
$('bus-subject').value = ev.target.getAttribute('data-s')
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── emit ─────────────────────────────────────────────────────────────────
|
|
47
|
+
$('bus-emit').addEventListener('click', function () {
|
|
48
|
+
var payload = parseJson($('bus-payload').value, null)
|
|
49
|
+
if (payload === null || typeof payload !== 'object') {
|
|
50
|
+
$('bus-emit-result').innerHTML = '<div class="alert alert-danger py-1 px-2 mb-0">payload 가 JSON 객체가 아닙니다.</div>'
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
postJson('/demo/bus/emit', {
|
|
54
|
+
subject: $('bus-subject').value,
|
|
55
|
+
payload: payload,
|
|
56
|
+
persist: $('bus-persist').checked,
|
|
57
|
+
ordered: $('bus-ordered').checked,
|
|
58
|
+
})
|
|
59
|
+
.then(function (d) {
|
|
60
|
+
$('bus-emit-result').innerHTML =
|
|
61
|
+
'<div class="alert alert-success py-1 px-2 mb-0">발행됨 · <code>' + esc(d.subject) + '</code>' + (d.persist ? ' · <span class="badge text-bg-info">persist</span>' : '') + ' · PID <code>' + esc(d.pid) + '</code></div>'
|
|
62
|
+
})
|
|
63
|
+
.catch(function (e) {
|
|
64
|
+
$('bus-emit-result').innerHTML = '<div class="alert alert-danger py-1 px-2 mb-0">오류: ' + esc(e.message) + '</div>'
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
// ── request ──────────────────────────────────────────────────────────────
|
|
69
|
+
$('bus-request').addEventListener('click', function () {
|
|
70
|
+
var payload = parseJson($('bus-req-payload').value, {})
|
|
71
|
+
$('bus-req-result').innerHTML = '<div class="text-body-secondary">요청 중…</div>'
|
|
72
|
+
postJson('/demo/bus/request', { subject: 'demo.echo', payload: payload, timeout: 2000 })
|
|
73
|
+
.then(function (d) {
|
|
74
|
+
if (!d.ok) {
|
|
75
|
+
$('bus-req-result').innerHTML = '<div class="alert alert-warning py-1 px-2 mb-0">' + esc(d.error) + '</div>'
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
$('bus-req-result').innerHTML =
|
|
79
|
+
'<div class="alert alert-success py-1 px-2 mb-0">응답 받음 · 답한 워커 PID <code>' + esc(d.reply && d.reply.pid) + '</code><br><code>' + esc(JSON.stringify(d.reply && d.reply.echo)) + '</code></div>'
|
|
80
|
+
})
|
|
81
|
+
.catch(function (e) {
|
|
82
|
+
$('bus-req-result').innerHTML = '<div class="alert alert-danger py-1 px-2 mb-0">오류: ' + esc(e.message) + '</div>'
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// ── 수신 폴링 ─────────────────────────────────────────────────────────────
|
|
87
|
+
var POLL_MS = 2000
|
|
88
|
+
function renderEvents(/** @type {any[]} */ rows) {
|
|
89
|
+
$('bus-events').innerHTML = (rows || [])
|
|
90
|
+
.map(function (r) {
|
|
91
|
+
var badge = r.persisted ? '<span class="badge text-bg-info">persist</span>' : '<span class="text-body-secondary">core</span>'
|
|
92
|
+
return (
|
|
93
|
+
'<tr><td class="text-body-secondary">' +
|
|
94
|
+
esc((r.at || '').slice(11, 19)) +
|
|
95
|
+
'</td><td><code>' +
|
|
96
|
+
esc(r.subject) +
|
|
97
|
+
'</code></td><td>' +
|
|
98
|
+
badge +
|
|
99
|
+
'</td><td>' +
|
|
100
|
+
esc(r.pid) +
|
|
101
|
+
'</td><td><code class="small">' +
|
|
102
|
+
esc(JSON.stringify(r.payload)) +
|
|
103
|
+
'</code></td></tr>'
|
|
104
|
+
)
|
|
105
|
+
})
|
|
106
|
+
.join('')
|
|
107
|
+
}
|
|
108
|
+
function poll() {
|
|
109
|
+
if (document.hidden) {
|
|
110
|
+
setTimeout(poll, POLL_MS)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
fetch('/demo/bus/events', { headers: { accept: 'application/json' } })
|
|
114
|
+
.then(function (res) {
|
|
115
|
+
return res.json()
|
|
116
|
+
})
|
|
117
|
+
.then(function (j) {
|
|
118
|
+
var d = j.data != null ? j.data : j
|
|
119
|
+
if (d.driver) $('bus-driver').textContent = d.driver
|
|
120
|
+
if (d.pid != null) $('bus-pid').textContent = String(d.pid)
|
|
121
|
+
renderEvents(d.events)
|
|
122
|
+
})
|
|
123
|
+
.catch(function () {
|
|
124
|
+
/* 비치명적 — 다음 주기에 재시도 */
|
|
125
|
+
})
|
|
126
|
+
.then(function () {
|
|
127
|
+
setTimeout(poll, POLL_MS)
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
poll()
|
|
131
|
+
})()
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/*
|
|
3
|
+
* /demo/lock 클라이언트 — 분산 락 데모(ADR-226).
|
|
4
|
+
*
|
|
5
|
+
* - 실행/시도: 버튼이 /demo/lock/run · /demo/lock/try 에 JSON POST(폼 아님 → CSRF 토큰 면제 + Origin 검증,
|
|
6
|
+
* ADR-051)로 옵션을 보내 ctx.lock.with / tryAcquire 결과(획득 여부·워커 PID·대기시간·fence)를 보여준다.
|
|
7
|
+
* - 상태: 2초마다 /demo/lock/status 를 폴링해 보유/대기 수(stats)와 워커별 최근 실행 로그를 갱신한다(탭 숨김 시 멈춤).
|
|
8
|
+
* 폴링 간격은 앱 rate limit(100/분, ADR-073) 안에 둔다.
|
|
9
|
+
*/
|
|
10
|
+
;(function () {
|
|
11
|
+
var $ = function (/** @type {string} */ id) {
|
|
12
|
+
return /** @type {any} */ (document.getElementById(id))
|
|
13
|
+
}
|
|
14
|
+
function esc(/** @type {any} */ s) {
|
|
15
|
+
return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) {
|
|
16
|
+
return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readBody() {
|
|
21
|
+
return {
|
|
22
|
+
key: $('lk-key').value || 'demo:resource',
|
|
23
|
+
ttl: Number($('lk-ttl').value) || 5000,
|
|
24
|
+
waitMs: Number($('lk-wait').value),
|
|
25
|
+
holdMs: Number($('lk-hold').value),
|
|
26
|
+
fifo: $('lk-fifo').checked,
|
|
27
|
+
fence: $('lk-fence').checked,
|
|
28
|
+
extendable: $('lk-ext').checked,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function postJson(/** @type {string} */ url, /** @type {any} */ body) {
|
|
33
|
+
return fetch(url, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
36
|
+
body: JSON.stringify(body),
|
|
37
|
+
}).then(function (res) {
|
|
38
|
+
return res.json().then(function (j) {
|
|
39
|
+
if (!res.ok) throw new Error((j && j.error && j.error.code) || 'http ' + res.status)
|
|
40
|
+
return j.data != null ? j.data : j
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function showResult(/** @type {any} */ d) {
|
|
46
|
+
var ok = d.acquired
|
|
47
|
+
var cls = ok ? 'alert-success' : 'alert-warning'
|
|
48
|
+
var label = ok ? '획득' : d.reason === 'held' ? '이미 보유 중(즉시 실패)' : '경합 — 대기 후 실패'
|
|
49
|
+
var bits = ['worker PID <code>' + esc(d.pid) + '</code>']
|
|
50
|
+
if (d.waitedMs != null) bits.push('대기 ' + esc(d.waitedMs) + 'ms')
|
|
51
|
+
if (d.heldMs != null) bits.push('보유 ' + esc(d.heldMs) + 'ms')
|
|
52
|
+
if (d.fence != null) bits.push('fence <code>' + esc(d.fence) + '</code>')
|
|
53
|
+
$('lk-result').innerHTML =
|
|
54
|
+
'<div class="alert ' + cls + ' py-2 px-3 mb-0"><strong>' + esc(label) + '</strong> · ' + bits.join(' · ') + '</div>'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function run(/** @type {string} */ url) {
|
|
58
|
+
$('lk-result').innerHTML = '<div class="text-body-secondary">실행 중…</div>'
|
|
59
|
+
postJson(url, readBody())
|
|
60
|
+
.then(showResult)
|
|
61
|
+
.catch(function (e) {
|
|
62
|
+
$('lk-result').innerHTML = '<div class="alert alert-danger py-2 px-3 mb-0">오류: ' + esc(e.message) + '</div>'
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
$('lk-run').addEventListener('click', function () {
|
|
67
|
+
run('/demo/lock/run')
|
|
68
|
+
})
|
|
69
|
+
$('lk-try').addEventListener('click', function () {
|
|
70
|
+
run('/demo/lock/try')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
// ── 상태 폴링 ─────────────────────────────────────────────────────────────
|
|
74
|
+
var POLL_MS = 2000
|
|
75
|
+
function renderLog(/** @type {any[]} */ rows) {
|
|
76
|
+
$('lk-log').innerHTML = (rows || [])
|
|
77
|
+
.map(function (r) {
|
|
78
|
+
var res = r.acquired ? '<span class="text-success">획득</span>' : '<span class="text-warning">' + (r.reason === 'held' ? 'held' : 'wait') + '</span>'
|
|
79
|
+
if (r.fence != null) res += ' f' + esc(r.fence)
|
|
80
|
+
return (
|
|
81
|
+
'<tr><td class="text-body-secondary">' +
|
|
82
|
+
esc((r.at || '').slice(11, 19)) +
|
|
83
|
+
'</td><td><code>' +
|
|
84
|
+
esc(r.key) +
|
|
85
|
+
'</code></td><td>' +
|
|
86
|
+
esc(r.pid) +
|
|
87
|
+
'</td><td>' +
|
|
88
|
+
res +
|
|
89
|
+
'</td><td>' +
|
|
90
|
+
(r.waitedMs != null ? esc(r.waitedMs) + 'ms' : '–') +
|
|
91
|
+
'</td></tr>'
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
.join('')
|
|
95
|
+
}
|
|
96
|
+
function poll() {
|
|
97
|
+
if (document.hidden) {
|
|
98
|
+
setTimeout(poll, POLL_MS)
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
fetch('/demo/lock/status', { headers: { accept: 'application/json' } })
|
|
102
|
+
.then(function (res) {
|
|
103
|
+
return res.json()
|
|
104
|
+
})
|
|
105
|
+
.then(function (j) {
|
|
106
|
+
var d = j.data != null ? j.data : j
|
|
107
|
+
if (d.stats) {
|
|
108
|
+
$('lk-active').textContent = String(d.stats.active)
|
|
109
|
+
$('lk-waiting').textContent = String(d.stats.waiting)
|
|
110
|
+
$('lk-driver').textContent = d.stats.driver
|
|
111
|
+
}
|
|
112
|
+
renderLog(d.runLog)
|
|
113
|
+
})
|
|
114
|
+
.catch(function () {
|
|
115
|
+
/* 폴링 실패는 비치명적 — 다음 주기에 재시도 */
|
|
116
|
+
})
|
|
117
|
+
.then(function () {
|
|
118
|
+
setTimeout(poll, POLL_MS)
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
poll()
|
|
122
|
+
})()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* apps/main/routes/bus.js — /demo/bus 메시지 버스 데모 UI 라우트(자동 로딩, loadRoutes, ADR-157).
|
|
4
|
+
*
|
|
5
|
+
* `ctx.bus.emit/.on/.request`(ADR-227) 사용자 메시지 버스 API 를 시연한다. 페이지는 `webRequireAuth`(ADR-155)로
|
|
6
|
+
* 보호한다. 발행/요청/수신조회는 페이지가 fetch(JSON)로 호출 — JSON POST 는 CSRF 면제 + Origin 검사라
|
|
7
|
+
* body 스키마는 `additionalProperties:false` 로 닫는다(ADR-074 envelope).
|
|
8
|
+
*/
|
|
9
|
+
import { BusController } from '../controllers/bus-controller.js'
|
|
10
|
+
import { webRequireAuth } from '../middleware/web-auth.js'
|
|
11
|
+
|
|
12
|
+
/** emit body — subject(구체, wildcard 금지는 매니저가 검증) + payload(임의 객체) + 옵트인 플래그. */
|
|
13
|
+
const emitBody = {
|
|
14
|
+
type: 'object',
|
|
15
|
+
required: ['subject'],
|
|
16
|
+
properties: {
|
|
17
|
+
subject: { type: 'string', minLength: 1, maxLength: 120 },
|
|
18
|
+
payload: { type: 'object' },
|
|
19
|
+
persist: { type: 'boolean' },
|
|
20
|
+
ordered: { type: 'boolean' },
|
|
21
|
+
},
|
|
22
|
+
additionalProperties: false,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** request body — subject + payload + timeout. */
|
|
26
|
+
const requestBody = {
|
|
27
|
+
type: 'object',
|
|
28
|
+
properties: {
|
|
29
|
+
subject: { type: 'string', minLength: 1, maxLength: 120 },
|
|
30
|
+
payload: { type: 'object' },
|
|
31
|
+
timeout: { type: 'integer', minimum: 100, maximum: 10_000 },
|
|
32
|
+
},
|
|
33
|
+
additionalProperties: false,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default (/** @type {any} */ router) => {
|
|
37
|
+
/** @type {{ before: Function[] }} 데모 UI 보호 가드(로그인 필요). */
|
|
38
|
+
const guarded = { before: [webRequireAuth] }
|
|
39
|
+
router.http.get('/demo/bus', BusController.index, guarded)
|
|
40
|
+
router.http.post('/demo/bus/emit', BusController.emit, { ...guarded, schema: { body: emitBody } })
|
|
41
|
+
router.http.post('/demo/bus/request', BusController.request, { ...guarded, schema: { body: requestBody } })
|
|
42
|
+
router.http.get('/demo/bus/events', BusController.events, guarded)
|
|
43
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* apps/main/routes/lock.js — /demo/lock 분산 락 데모 UI 라우트(자동 로딩, loadRoutes, ADR-157).
|
|
4
|
+
*
|
|
5
|
+
* `ctx.lock.with/.tryAcquire`(ADR-226) 사용자 분산 락 API 를 시연한다. 페이지는 `webRequireAuth`(ADR-155)로
|
|
6
|
+
* 보호한다. 락 실행/시도/상태 조회는 페이지가 fetch(JSON)로 호출한다 — JSON POST 는 CSRF 토큰 면제 + Origin
|
|
7
|
+
* 검사라(ADR-074 envelope) body 스키마는 `additionalProperties:false` 로 닫는다.
|
|
8
|
+
*/
|
|
9
|
+
import { LockController } from '../controllers/lock-controller.js'
|
|
10
|
+
import { webRequireAuth } from '../middleware/web-auth.js'
|
|
11
|
+
|
|
12
|
+
/** run/tryRun body 스키마 — 타입·범위만 본다(ttl/waitMs/holdMs 는 데모 안전 상한). */
|
|
13
|
+
const runBody = {
|
|
14
|
+
type: 'object',
|
|
15
|
+
required: ['key'],
|
|
16
|
+
properties: {
|
|
17
|
+
key: { type: 'string', minLength: 1, maxLength: 80 },
|
|
18
|
+
ttl: { type: 'integer', minimum: 100, maximum: 60_000 },
|
|
19
|
+
waitMs: { type: 'integer', minimum: 0, maximum: 30_000 },
|
|
20
|
+
holdMs: { type: 'integer', minimum: 0, maximum: 15_000 },
|
|
21
|
+
fifo: { type: 'boolean' },
|
|
22
|
+
fence: { type: 'boolean' },
|
|
23
|
+
extendable: { type: 'boolean' },
|
|
24
|
+
},
|
|
25
|
+
additionalProperties: false,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default (/** @type {any} */ router) => {
|
|
29
|
+
/** @type {{ before: Function[] }} 데모 UI 보호 가드(로그인 필요). */
|
|
30
|
+
const guarded = { before: [webRequireAuth] }
|
|
31
|
+
router.http.get('/demo/lock', LockController.index, guarded)
|
|
32
|
+
router.http.post('/demo/lock/run', LockController.run, { ...guarded, schema: { body: runBody } })
|
|
33
|
+
router.http.post('/demo/lock/try', LockController.tryRun, { ...guarded, schema: { body: runBody } })
|
|
34
|
+
router.http.get('/demo/lock/status', LockController.status, guarded)
|
|
35
|
+
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { MegaService, MegaJobQueue } from 'mega-framework'
|
|
3
|
+
// raw nats 핸들(`.native`, ADR-009)로 DLQ 스트림을 직접 읽으려면 드라이버 v3 API 를 쓴다(ADR-225):
|
|
4
|
+
// jetstreamManager 는 함수, not-found 는 JetStreamApiError(code) 로 바뀌었다. .native 는 버전-결합 escape hatch.
|
|
5
|
+
import { jetstreamManager, JetStreamApiError, JetStreamApiCodes } from '@nats-io/jetstream'
|
|
3
6
|
import { EmailJob } from '../jobs/email-job.js'
|
|
4
7
|
|
|
5
|
-
/** JetStream
|
|
6
|
-
const
|
|
8
|
+
/** JetStream stream-not-found 판별(v3) — `e instanceof JetStreamApiError && code===StreamNotFound`. @param {unknown} e @returns {boolean} */
|
|
9
|
+
const isStreamNotFound = (e) => e instanceof JetStreamApiError && e.code === JetStreamApiCodes.StreamNotFound
|
|
7
10
|
|
|
8
11
|
/**
|
|
9
12
|
* EmailJob 의 DLQ 스트림/서브젝트 이름. MegaJobQueue 는 `<subject>.dlq` 로 발행하고 DLQ 스트림을
|
|
@@ -25,7 +28,7 @@ const DLQ_STREAM = `MEGA_JOBS_${EmailJob.subject.replace(/[^A-Za-z0-9_-]/g, '_')
|
|
|
25
28
|
export class JobsDemoService extends MegaService {
|
|
26
29
|
/**
|
|
27
30
|
* EmailJob 1건을 큐에 넣는다(JetStream publish — 서버에 영속 저장 후 워커가 가져감).
|
|
28
|
-
* @param {{ id: string, to: string, subject?: string, mode: 'ok'|'flaky'|'fail' }} payload
|
|
31
|
+
* @param {{ id: string, to: string, subject?: string, mode: 'ok'|'flaky'|'fail'|'hang', delayMs?: number }} payload
|
|
29
32
|
* @returns {Promise<{ seq: number, stream: string, duplicate: boolean }>}
|
|
30
33
|
*/
|
|
31
34
|
async enqueue(payload) {
|
|
@@ -53,30 +56,34 @@ export class JobsDemoService extends MegaService {
|
|
|
53
56
|
*/
|
|
54
57
|
async dlq() {
|
|
55
58
|
const nc = this.ctx.bus('jobs').native
|
|
56
|
-
const jsm = await
|
|
59
|
+
const jsm = await jetstreamManager(nc)
|
|
57
60
|
let count = 0
|
|
58
61
|
try {
|
|
59
62
|
const info = await jsm.streams.info(DLQ_STREAM)
|
|
60
63
|
count = info.state.messages
|
|
61
64
|
} catch (e) {
|
|
62
|
-
// 스트림
|
|
63
|
-
if (
|
|
64
|
-
return { count
|
|
65
|
+
// 스트림 미존재는 "DLQ 로 간 잡이 아직 없음" — 빈 상태(count=0)로 본다. 그 외(연결 장애 등)는 전파.
|
|
66
|
+
if (!isStreamNotFound(e)) throw e
|
|
67
|
+
return { count, latest: null }
|
|
65
68
|
}
|
|
66
69
|
if (count === 0) return { count, latest: null }
|
|
67
70
|
let latest = null
|
|
68
71
|
try {
|
|
72
|
+
// v3(ADR-225): getMessage 는 메시지 없음을 **null** 로 반환한다(v2 는 404 throw 였다). count>0 직후
|
|
73
|
+
// 경쟁 삭제로 null 이면 latest 없이 카운트만 보여준다.
|
|
69
74
|
const msg = await jsm.streams.getMessage(DLQ_STREAM, { last_by_subj: DLQ_SUBJECT })
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
75
|
+
if (msg) {
|
|
76
|
+
const body = /** @type {any} */ (msg.json())
|
|
77
|
+
latest = {
|
|
78
|
+
failedAt: body.failedAt,
|
|
79
|
+
deliveryCount: body.deliveryCount,
|
|
80
|
+
error: body.error?.message ?? String(body.error ?? ''),
|
|
81
|
+
payload: body.payload,
|
|
82
|
+
}
|
|
76
83
|
}
|
|
77
84
|
} catch (e) {
|
|
78
|
-
//
|
|
79
|
-
if (
|
|
85
|
+
// 카운트 조회 후 스트림이 통째로 사라진 race 면 latest 없이 카운트만. 그 외(연결 장애 등)는 전파.
|
|
86
|
+
if (!isStreamNotFound(e)) throw e
|
|
80
87
|
}
|
|
81
88
|
return { count, latest }
|
|
82
89
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<% layout('layouts/main') %>
|
|
2
|
+
|
|
3
|
+
<div class="mb-4">
|
|
4
|
+
<h1 class="h3 mb-1"><%= t('bus_title', { defaultValue: '메시지 버스 데모' }) %></h1>
|
|
5
|
+
<p class="text-body-secondary small mb-0">
|
|
6
|
+
<%= t('bus_subtitle', { defaultValue: 'ctx.bus.emit / on / request 로 이벤트를 주고받습니다. 이 페이지는 demo.> 를 구독해 수신 이벤트를 실시간(폴링) 표시합니다 — 다른 워커가 발행해도 fan-out 으로 도착합니다.' }) %>
|
|
7
|
+
<a href="/guide" class="ms-1">ADR-227 · 가이드</a>
|
|
8
|
+
</p>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<div class="row g-3">
|
|
12
|
+
<!-- 발행 + 요청 -->
|
|
13
|
+
<div class="col-lg-5">
|
|
14
|
+
<div class="card mb-3">
|
|
15
|
+
<div class="card-body">
|
|
16
|
+
<h2 class="h5 card-title"><%= t('bus_emit_title', { defaultValue: '발행 (emit)' }) %></h2>
|
|
17
|
+
<p class="card-text text-body-secondary small mb-2"><%= t('bus_emit_desc', { defaultValue: 'subject 와 payload(JSON)를 fan-out 발행합니다. persist:true 면 JetStream 에 저장됩니다.' }) %></p>
|
|
18
|
+
<label class="form-label small mb-1" for="bus-subject">subject</label>
|
|
19
|
+
<input id="bus-subject" class="form-control form-control-sm mb-2" value="demo.order.created" maxlength="120">
|
|
20
|
+
<div class="d-flex flex-wrap gap-1 mb-2">
|
|
21
|
+
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-2 bus-preset" data-s="demo.order.created">order.created</button>
|
|
22
|
+
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-2 bus-preset" data-s="demo.order.created.eu">order.created.eu</button>
|
|
23
|
+
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-2 bus-preset" data-s="demo.user.login">user.login</button>
|
|
24
|
+
</div>
|
|
25
|
+
<label class="form-label small mb-1" for="bus-payload">payload (JSON)</label>
|
|
26
|
+
<textarea id="bus-payload" class="form-control form-control-sm font-monospace" rows="3">{ "orderId": 42 }</textarea>
|
|
27
|
+
<div class="d-flex flex-wrap gap-3 mt-2">
|
|
28
|
+
<div class="form-check form-switch">
|
|
29
|
+
<input id="bus-persist" class="form-check-input" type="checkbox">
|
|
30
|
+
<label class="form-check-label small" for="bus-persist">persist <span class="text-body-secondary">(JetStream)</span></label>
|
|
31
|
+
</div>
|
|
32
|
+
<div class="form-check form-switch">
|
|
33
|
+
<input id="bus-ordered" class="form-check-input" type="checkbox">
|
|
34
|
+
<label class="form-check-label small" for="bus-ordered">ordered</label>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
<button id="bus-emit" class="btn btn-primary btn-sm mt-2"><%= t('bus_btn_emit', { defaultValue: 'Emit' }) %></button>
|
|
38
|
+
<div id="bus-emit-result" class="mt-2 small"></div>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<div class="card">
|
|
43
|
+
<div class="card-body">
|
|
44
|
+
<h2 class="h5 card-title"><%= t('bus_req_title', { defaultValue: '요청/응답 (request)' }) %></h2>
|
|
45
|
+
<p class="card-text text-body-secondary small mb-2"><%= t('bus_req_desc', { defaultValue: 'demo.echo 응답자가 첫 응답을 돌려줍니다 — 어느 워커가 답했는지 PID 로 보입니다.' }) %></p>
|
|
46
|
+
<label class="form-label small mb-1" for="bus-req-payload">payload (JSON)</label>
|
|
47
|
+
<textarea id="bus-req-payload" class="form-control form-control-sm font-monospace" rows="2">{ "ping": 1 }</textarea>
|
|
48
|
+
<button id="bus-request" class="btn btn-outline-primary btn-sm mt-2"><%= t('bus_btn_request', { defaultValue: 'Request demo.echo' }) %></button>
|
|
49
|
+
<div id="bus-req-result" class="mt-2 small"></div>
|
|
50
|
+
<div class="mt-2"><code class="small">ctx.bus.request('demo.echo', payload, { timeout })</code></div>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<!-- 수신 패널 -->
|
|
56
|
+
<div class="col-lg-7">
|
|
57
|
+
<div class="card h-100">
|
|
58
|
+
<div class="card-body d-flex flex-column">
|
|
59
|
+
<div class="d-flex justify-content-between align-items-center">
|
|
60
|
+
<h2 class="h5 card-title mb-0"><%= t('bus_recv_title', { defaultValue: '수신 이벤트' }) %></h2>
|
|
61
|
+
<div class="small">
|
|
62
|
+
driver <span class="badge text-bg-secondary" id="bus-driver"><%= stats ? stats.driver : '?' %></span>
|
|
63
|
+
· <%= t('bus_recv_pid', { defaultValue: '구독 워커' }) %> <code id="bus-pid"><%= pid %></code>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
<p class="text-body-secondary small mb-2">
|
|
67
|
+
<%= t('bus_recv_desc', { defaultValue: '서버가 demo.> 를 구독합니다 — * 는 한 토큰, > 는 꼬리 전체. order.created / order.created.eu / user.login 모두 demo.> 에 매칭됩니다. persist 이벤트는 배지로 구분됩니다.' }) %>
|
|
68
|
+
</p>
|
|
69
|
+
<div class="table-responsive flex-grow-1" style="max-height: 520px; overflow-y: auto;">
|
|
70
|
+
<table class="table table-sm small mb-0">
|
|
71
|
+
<thead><tr><th>at</th><th>subject</th><th>persist</th><th><%= t('bus_recv_pid_col', { defaultValue: '수신 워커' }) %></th><th>payload</th></tr></thead>
|
|
72
|
+
<tbody id="bus-events"></tbody>
|
|
73
|
+
</table>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<script src="/static/js/bus-demo.js"></script>
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
<% layout('layouts/main') %>
|
|
2
2
|
<%
|
|
3
3
|
function fmt(d) { return new Date(d).toLocaleString('ko-KR', { hour12: false }) }
|
|
4
|
-
function statusBadge(s) { return s === 'sent' ? 'text-bg-success' : (s === 'retry' ? 'text-bg-warning' : 'text-bg-danger') }
|
|
5
|
-
function modeBadge(m) { return m === 'ok' ? 'text-bg-success' : (m === 'flaky' ? 'text-bg-warning' : 'text-bg-danger') }
|
|
4
|
+
function statusBadge(s) { return s === 'sent' ? 'text-bg-success' : (s === 'retry' ? 'text-bg-warning' : (s === 'running' ? 'text-bg-info' : 'text-bg-danger')) }
|
|
5
|
+
function modeBadge(m) { return m === 'ok' ? 'text-bg-success' : (m === 'flaky' ? 'text-bg-warning' : (m === 'hang' ? 'text-bg-info' : 'text-bg-danger')) }
|
|
6
6
|
%>
|
|
7
7
|
|
|
8
8
|
<div class="mb-4">
|
|
@@ -39,9 +39,15 @@
|
|
|
39
39
|
<div class="form-text small">
|
|
40
40
|
<%= t('jobs_mode_ok', { defaultValue: '성공' }) %>: <%= t('jobs_mode_ok_hint', { defaultValue: '첫 시도에 바로 성공' }) %><br />
|
|
41
41
|
<%= t('jobs_mode_flaky', { defaultValue: '재시도' }) %>: <%= t('jobs_mode_flaky_hint', { defaultValue: '1번째 시도 실패 → 재시도 → 2번째 성공' }) %><br />
|
|
42
|
-
<%= t('jobs_mode_fail', { defaultValue: '영구 실패' }) %>: <%= t('jobs_mode_fail_hint', { defaultValue: '매 시도 실패 → 재시도 소진 후 DLQ 격리' })
|
|
42
|
+
<%= t('jobs_mode_fail', { defaultValue: '영구 실패' }) %>: <%= t('jobs_mode_fail_hint', { defaultValue: '매 시도 실패 → 재시도 소진 후 DLQ 격리' }) %><br />
|
|
43
|
+
<%= t('jobs_mode_hang', { defaultValue: '타임아웃' }) %>: <%= t('jobs_mode_hang_hint', { defaultValue: 'delayMs 만큼 지연 → 5초(timeoutMs) 초과 시 timeout → DLQ' }) %>
|
|
43
44
|
</div>
|
|
44
45
|
</div>
|
|
46
|
+
<div class="mb-3">
|
|
47
|
+
<label for="delayMs" class="form-label small"><%= t('jobs_field_delay', { defaultValue: '지연(ms) — hang 모드 전용' }) %></label>
|
|
48
|
+
<input type="number" class="form-control form-control-sm" id="delayMs" name="delayMs" min="0" max="600000" step="500" placeholder="8000" />
|
|
49
|
+
<div class="form-text small"><%= t('jobs_field_delay_hint', { defaultValue: '비워두면 기본 8000ms. 5000ms(timeoutMs) 초과 시 timeout 으로 DLQ 격리됩니다.' }) %></div>
|
|
50
|
+
</div>
|
|
45
51
|
<button type="submit" class="btn btn-primary btn-sm"><%= t('jobs_enqueue_btn', { defaultValue: '큐에 넣기' }) %></button>
|
|
46
52
|
<a href="/demo/jobs" class="btn btn-outline-secondary btn-sm ms-1"><%= t('jobs_reload', { defaultValue: '새로고침' }) %></a>
|
|
47
53
|
</form>
|
|
@@ -45,6 +45,8 @@
|
|
|
45
45
|
<li><a class="dropdown-item" href="/demo/ws"><%= t('nav_ws', '채팅 (WS+ASP)') %></a></li>
|
|
46
46
|
<li><a class="dropdown-item" href="/demo/cron"><%= t('nav_cron', '스케줄러 (Cron)') %></a></li>
|
|
47
47
|
<li><a class="dropdown-item" href="/demo/jobs"><%= t('nav_jobs', '잡 큐 (NATS)') %></a></li>
|
|
48
|
+
<li><a class="dropdown-item" href="/demo/lock"><%= t('nav_lock', '분산 락 (Lock)') %></a></li>
|
|
49
|
+
<li><a class="dropdown-item" href="/demo/bus"><%= t('nav_bus', '메시지 버스 (Bus)') %></a></li>
|
|
48
50
|
<li><a class="dropdown-item" href="/demo/worker"><%= t('nav_worker', '워커 (Threads)') %></a></li>
|
|
49
51
|
<li><hr class="dropdown-divider" /></li>
|
|
50
52
|
<li><a class="dropdown-item" href="/demo/metrics"><%= t('nav_metrics', '메트릭') %></a></li>
|