@rezti/dsh-rez-suite 0.1.0
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/LICENSE +13 -0
- package/README.md +9 -0
- package/README.zh.md +11 -0
- package/cordis.patch.yml +68 -0
- package/lib/client.d.ts +7479 -0
- package/lib/client.js +1190 -0
- package/lib/index.d.ts +1726 -0
- package/lib/index.js +7659 -0
- package/lib/style.css +309 -0
- package/package.json +103 -0
- package/servers/fs-mcp-server.mjs +71 -0
- package/servers/sqlite-mcp-server.mjs +50 -0
- package/src/client/api.ts +75 -0
- package/src/client/css-modules.d.ts +4 -0
- package/src/client/index.ts +45 -0
- package/src/client/locales.ts +176 -0
- package/src/client/mount.tsx +97 -0
- package/src/client/panel/AuditTab.tsx +87 -0
- package/src/client/panel/ConfigTab.tsx +223 -0
- package/src/client/panel/RezPanel.tsx +50 -0
- package/src/client/panel/StatusTab.tsx +66 -0
- package/src/client/panel/controller.ts +42 -0
- package/src/client/panel/helpers.ts +21 -0
- package/src/client/panel/panel.module.css +316 -0
- package/src/client/sidebar-entry.ts +80 -0
- package/src/index.ts +166 -0
- package/src/mcp-host.ts +418 -0
- package/src/presets.ts +125 -0
- package/src/protocol.ts +133 -0
- package/src/routes.ts +191 -0
- package/src/store.ts +152 -0
- package/src/token-manager.ts +252 -0
- package/src/tools.ts +167 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-rez-suite surface copy: zh is the key source, en mirrors every key.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const zh = {
|
|
6
|
+
'entry.label': 'Rez',
|
|
7
|
+
'entry.tooltip': 'Rez Suite 配置与审计面板',
|
|
8
|
+
'panel.title': 'Rez Suite',
|
|
9
|
+
'tab.config': '配置',
|
|
10
|
+
'tab.status': '状态',
|
|
11
|
+
'tab.audit': '用量',
|
|
12
|
+
// config
|
|
13
|
+
'config.role': '角色',
|
|
14
|
+
'config.roleHint': '决定注册给模型的 MCP 工具',
|
|
15
|
+
'role.all': '全部',
|
|
16
|
+
'role.engineer': '工程师',
|
|
17
|
+
'role.sales': '销售',
|
|
18
|
+
'role.operations': '运营',
|
|
19
|
+
'config.enabled': '启用插件',
|
|
20
|
+
'config.transport': '传输方式',
|
|
21
|
+
'config.command': '命令',
|
|
22
|
+
'config.args': '参数(每行一个)',
|
|
23
|
+
'config.url': 'URL',
|
|
24
|
+
'config.env': '环境变量 (JSON)',
|
|
25
|
+
'config.headers': 'Headers (JSON)',
|
|
26
|
+
'config.jsonInvalid': 'JSON 格式错误:{error}',
|
|
27
|
+
'config.odoo': 'Odoo',
|
|
28
|
+
'config.odoo.url': 'Odoo URL',
|
|
29
|
+
'config.odoo.apiKey': 'API Key',
|
|
30
|
+
'config.nextcloud': 'Nextcloud',
|
|
31
|
+
'config.nextcloud.url': 'Nextcloud URL',
|
|
32
|
+
'config.nextcloud.username': '用户名',
|
|
33
|
+
'config.nextcloud.appPassword': 'App Password',
|
|
34
|
+
'config.wecom': '企业微信',
|
|
35
|
+
'config.wecom.botId': 'Bot ID',
|
|
36
|
+
'config.wecom.secret': 'Secret',
|
|
37
|
+
'config.fs': '本地文件 (fs-mcp-server)',
|
|
38
|
+
'config.fs.root': '根目录',
|
|
39
|
+
'config.sqlite': 'SQLite (sqlite-mcp-server)',
|
|
40
|
+
'config.sqlite.path': '数据库路径',
|
|
41
|
+
'config.billing': '计费 / 审计',
|
|
42
|
+
'config.billing.input': '输入单价 / 1k token',
|
|
43
|
+
'config.billing.output': '输出单价 / 1k token',
|
|
44
|
+
'config.billing.budget': '月预算',
|
|
45
|
+
'config.save': '保存',
|
|
46
|
+
'config.test': '测试连接',
|
|
47
|
+
'config.testing': '测试中…',
|
|
48
|
+
'config.saved': '已保存并应用',
|
|
49
|
+
'config.testResult': '{server}: {status}',
|
|
50
|
+
'config.testOk': '成功({tools} 个工具, {latency}ms)',
|
|
51
|
+
'config.testFail': '失败:{error}',
|
|
52
|
+
// status
|
|
53
|
+
'status.total': '当前角色 {role},共注册 {count} 个 MCP 工具',
|
|
54
|
+
'status.col.server': 'Server',
|
|
55
|
+
'status.col.state': '状态',
|
|
56
|
+
'status.col.tools': '发现',
|
|
57
|
+
'status.col.registered': '已注册',
|
|
58
|
+
'status.col.error': '错误',
|
|
59
|
+
'status.none': '暂无状态',
|
|
60
|
+
// audit
|
|
61
|
+
'audit.totalCalls': '调用次数',
|
|
62
|
+
'audit.inputTokens': '输入 token',
|
|
63
|
+
'audit.outputTokens': '输出 token',
|
|
64
|
+
'audit.totalCost': '累计费用',
|
|
65
|
+
'audit.monthCost': '本月费用',
|
|
66
|
+
'audit.budget': '月预算',
|
|
67
|
+
'audit.byServer': '按服务统计',
|
|
68
|
+
'audit.recent': '最近记录',
|
|
69
|
+
'audit.col.time': '时间',
|
|
70
|
+
'audit.col.server': '服务',
|
|
71
|
+
'audit.col.tool': '工具',
|
|
72
|
+
'audit.col.tokens': 'token',
|
|
73
|
+
'audit.col.cost': '费用',
|
|
74
|
+
'audit.col.status': '状态',
|
|
75
|
+
'audit.ok': '成功',
|
|
76
|
+
'audit.fail': '失败',
|
|
77
|
+
'audit.reset': '清空账本',
|
|
78
|
+
'audit.resetConfirm': '确定清空全部用量记录吗?',
|
|
79
|
+
'audit.empty': '暂无记录',
|
|
80
|
+
// common
|
|
81
|
+
'common.close': '关闭',
|
|
82
|
+
'common.loading': '加载中…',
|
|
83
|
+
'common.error': '出错:{error}',
|
|
84
|
+
'common.refresh': '刷新',
|
|
85
|
+
} as const
|
|
86
|
+
|
|
87
|
+
export type RezKey = keyof typeof zh
|
|
88
|
+
|
|
89
|
+
export const en: Record<RezKey, string> = {
|
|
90
|
+
'entry.label': 'Rez',
|
|
91
|
+
'entry.tooltip': 'Rez Suite configuration and audit panel',
|
|
92
|
+
'panel.title': 'Rez Suite',
|
|
93
|
+
'tab.config': 'Config',
|
|
94
|
+
'tab.status': 'Status',
|
|
95
|
+
'tab.audit': 'Usage',
|
|
96
|
+
'config.role': 'Role',
|
|
97
|
+
'config.roleHint': 'Which MCP tools are registered for the model',
|
|
98
|
+
'role.all': 'All',
|
|
99
|
+
'role.engineer': 'Engineer',
|
|
100
|
+
'role.sales': 'Sales',
|
|
101
|
+
'role.operations': 'Operations',
|
|
102
|
+
'config.enabled': 'Enable plugin',
|
|
103
|
+
'config.transport': 'Transport',
|
|
104
|
+
'config.command': 'Command',
|
|
105
|
+
'config.args': 'Args (one per line)',
|
|
106
|
+
'config.url': 'URL',
|
|
107
|
+
'config.env': 'Env vars (JSON)',
|
|
108
|
+
'config.headers': 'Headers (JSON)',
|
|
109
|
+
'config.jsonInvalid': 'Invalid JSON: {error}',
|
|
110
|
+
'config.odoo': 'Odoo',
|
|
111
|
+
'config.odoo.url': 'Odoo URL',
|
|
112
|
+
'config.odoo.apiKey': 'API Key',
|
|
113
|
+
'config.nextcloud': 'Nextcloud',
|
|
114
|
+
'config.nextcloud.url': 'Nextcloud URL',
|
|
115
|
+
'config.nextcloud.username': 'Username',
|
|
116
|
+
'config.nextcloud.appPassword': 'App Password',
|
|
117
|
+
'config.wecom': 'WeCom',
|
|
118
|
+
'config.wecom.botId': 'Bot ID',
|
|
119
|
+
'config.wecom.secret': 'Secret',
|
|
120
|
+
'config.fs': 'Local files (fs-mcp-server)',
|
|
121
|
+
'config.fs.root': 'Root directory',
|
|
122
|
+
'config.sqlite': 'SQLite (sqlite-mcp-server)',
|
|
123
|
+
'config.sqlite.path': 'Database path',
|
|
124
|
+
'config.billing': 'Billing / audit',
|
|
125
|
+
'config.billing.input': 'Input price / 1k tokens',
|
|
126
|
+
'config.billing.output': 'Output price / 1k tokens',
|
|
127
|
+
'config.billing.budget': 'Monthly budget',
|
|
128
|
+
'config.save': 'Save',
|
|
129
|
+
'config.test': 'Test connections',
|
|
130
|
+
'config.testing': 'Testing…',
|
|
131
|
+
'config.saved': 'Saved and applied',
|
|
132
|
+
'config.testResult': '{server}: {status}',
|
|
133
|
+
'config.testOk': 'OK ({tools} tools, {latency}ms)',
|
|
134
|
+
'config.testFail': 'Failed: {error}',
|
|
135
|
+
'status.total': 'Role {role}, {count} MCP tools registered',
|
|
136
|
+
'status.col.server': 'Server',
|
|
137
|
+
'status.col.state': 'State',
|
|
138
|
+
'status.col.tools': 'Discovered',
|
|
139
|
+
'status.col.registered': 'Registered',
|
|
140
|
+
'status.col.error': 'Error',
|
|
141
|
+
'status.none': 'No status yet',
|
|
142
|
+
'audit.totalCalls': 'Calls',
|
|
143
|
+
'audit.inputTokens': 'Input tokens',
|
|
144
|
+
'audit.outputTokens': 'Output tokens',
|
|
145
|
+
'audit.totalCost': 'Total cost',
|
|
146
|
+
'audit.monthCost': 'Month cost',
|
|
147
|
+
'audit.budget': 'Monthly budget',
|
|
148
|
+
'audit.byServer': 'By server',
|
|
149
|
+
'audit.recent': 'Recent records',
|
|
150
|
+
'audit.col.time': 'Time',
|
|
151
|
+
'audit.col.server': 'Server',
|
|
152
|
+
'audit.col.tool': 'Tool',
|
|
153
|
+
'audit.col.tokens': 'Tokens',
|
|
154
|
+
'audit.col.cost': 'Cost',
|
|
155
|
+
'audit.col.status': 'Status',
|
|
156
|
+
'audit.ok': 'OK',
|
|
157
|
+
'audit.fail': 'Failed',
|
|
158
|
+
'audit.reset': 'Clear ledger',
|
|
159
|
+
'audit.resetConfirm': 'Clear all usage records?',
|
|
160
|
+
'audit.empty': 'No records',
|
|
161
|
+
'common.close': 'Close',
|
|
162
|
+
'common.loading': 'Loading…',
|
|
163
|
+
'common.error': 'Error: {error}',
|
|
164
|
+
'common.refresh': 'Refresh',
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Minimal {name} interpolator shared by the panel helpers. */
|
|
168
|
+
export function t(dict: Record<string, string>, key: RezKey, values?: Record<string, string | number>): string {
|
|
169
|
+
let text = dict[key] ?? key
|
|
170
|
+
if (values !== undefined) {
|
|
171
|
+
for (const [name, value] of Object.entries(values)) {
|
|
172
|
+
text = text.replaceAll('{' + name + '}', String(value))
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return text
|
|
176
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rez panel view mounting. Single-occupant center-column takeover, same
|
|
3
|
+
* mechanism as the task board and ssh panels. The conversation subtree stays
|
|
4
|
+
* mounted under the panel.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
8
|
+
import type { RezApi } from './api.ts'
|
|
9
|
+
import type { PanelController } from './panel/controller.ts'
|
|
10
|
+
import { RezPanel } from './panel/RezPanel.tsx'
|
|
11
|
+
import css from './panel/panel.module.css'
|
|
12
|
+
|
|
13
|
+
export const PANEL_VIEW_SELECTOR = '[data-dsh-rez-view]'
|
|
14
|
+
|
|
15
|
+
const CONVERSATION_COLUMN_SELECTOR = '[data-pane="conversation"]'
|
|
16
|
+
const ACTIVE_ATTR = 'data-dsh-rez-active'
|
|
17
|
+
const OTHER_ACTIVE_ATTRS = ['data-dsh-taskboard-active', 'data-dsh-ssh-active']
|
|
18
|
+
const ACTIVATE_EVENT = 'dsh-panel-activate'
|
|
19
|
+
const PANEL_NAME = 'rez'
|
|
20
|
+
|
|
21
|
+
function conversationColumn(): HTMLElement | undefined {
|
|
22
|
+
return document.querySelector<HTMLElement>(CONVERSATION_COLUMN_SELECTOR) ?? undefined
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function mountPanel(controller: PanelController, api: RezApi): () => void {
|
|
26
|
+
let root: Root | undefined
|
|
27
|
+
let container: HTMLDivElement | undefined
|
|
28
|
+
|
|
29
|
+
const ensure = (): void => {
|
|
30
|
+
if (container !== undefined) {
|
|
31
|
+
if (container.isConnected) return
|
|
32
|
+
root?.unmount()
|
|
33
|
+
root = undefined
|
|
34
|
+
container.remove()
|
|
35
|
+
container = undefined
|
|
36
|
+
}
|
|
37
|
+
const column = conversationColumn()
|
|
38
|
+
if (column === undefined) return
|
|
39
|
+
container = document.createElement('div')
|
|
40
|
+
container.dataset.dshRezView = ''
|
|
41
|
+
container.className = css.view
|
|
42
|
+
column.appendChild(container)
|
|
43
|
+
root = createRoot(container)
|
|
44
|
+
root.render(<RezPanel controller={controller} api={api} />)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const waitObserver = new MutationObserver(() => { ensure() })
|
|
48
|
+
waitObserver.observe(document.body, { childList: true, subtree: true })
|
|
49
|
+
|
|
50
|
+
// The shell may mount the center column after this plugin applies. Retry
|
|
51
|
+
// until the container is attached; ensure() is idempotent once connected.
|
|
52
|
+
const retryTimer = window.setInterval(() => {
|
|
53
|
+
ensure()
|
|
54
|
+
if (container?.isConnected === true) window.clearInterval(retryTimer)
|
|
55
|
+
}, 250)
|
|
56
|
+
|
|
57
|
+
const applyActive = (): void => {
|
|
58
|
+
if (controller.getSnapshot().panelOpen) {
|
|
59
|
+
for (const attr of OTHER_ACTIVE_ATTRS) document.documentElement.removeAttribute(attr)
|
|
60
|
+
document.documentElement.setAttribute(ACTIVE_ATTR, '')
|
|
61
|
+
document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL_NAME }))
|
|
62
|
+
} else {
|
|
63
|
+
document.documentElement.removeAttribute(ACTIVE_ATTR)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const onOtherActivate = (event: Event): void => {
|
|
67
|
+
const detail = (event as CustomEvent).detail
|
|
68
|
+
if ((detail === 'taskboard' || detail === 'ssh') && controller.getSnapshot().panelOpen) {
|
|
69
|
+
controller.close()
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const SIDEBAR_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="searchResultWorkspace"], [class*="newSession"]'
|
|
73
|
+
const onClickSidebarRow = (event: MouseEvent): void => {
|
|
74
|
+
if (!controller.getSnapshot().panelOpen) return
|
|
75
|
+
const target = event.target as HTMLElement | null
|
|
76
|
+
if (target === null) return
|
|
77
|
+
if (target.closest(SIDEBAR_ROW_SELECTOR) !== null) controller.close()
|
|
78
|
+
}
|
|
79
|
+
document.addEventListener('click', onClickSidebarRow, true)
|
|
80
|
+
document.addEventListener(ACTIVATE_EVENT, onOtherActivate)
|
|
81
|
+
const unsubscribe = controller.subscribe(applyActive)
|
|
82
|
+
applyActive()
|
|
83
|
+
ensure()
|
|
84
|
+
|
|
85
|
+
return () => {
|
|
86
|
+
window.clearInterval(retryTimer)
|
|
87
|
+
document.removeEventListener('click', onClickSidebarRow, true)
|
|
88
|
+
document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
|
|
89
|
+
waitObserver.disconnect()
|
|
90
|
+
unsubscribe()
|
|
91
|
+
document.documentElement.removeAttribute(ACTIVE_ATTR)
|
|
92
|
+
root?.unmount()
|
|
93
|
+
root = undefined
|
|
94
|
+
container?.remove()
|
|
95
|
+
container = undefined
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit tab: token/cost summary and recent ledger rows.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { useEffect, useState } from 'react'
|
|
6
|
+
import type { RezApi } from '../api.ts'
|
|
7
|
+
import type { RezAuditResponse } from '../../protocol.ts'
|
|
8
|
+
import { errorMessage, tt } from './helpers.ts'
|
|
9
|
+
import css from './panel.module.css'
|
|
10
|
+
|
|
11
|
+
export function AuditTab({ api }: { api: RezApi }) {
|
|
12
|
+
const [audit, setAudit] = useState<RezAuditResponse | null>(null)
|
|
13
|
+
const [error, setError] = useState('')
|
|
14
|
+
const [loading, setLoading] = useState(true)
|
|
15
|
+
|
|
16
|
+
const load = async (): Promise<void> => {
|
|
17
|
+
setLoading(true)
|
|
18
|
+
setError('')
|
|
19
|
+
try {
|
|
20
|
+
setAudit(await api.audit())
|
|
21
|
+
} catch (err) {
|
|
22
|
+
setError(errorMessage(err))
|
|
23
|
+
} finally {
|
|
24
|
+
setLoading(false)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
useEffect(() => { void load() }, [])
|
|
29
|
+
|
|
30
|
+
const reset = async (): Promise<void> => {
|
|
31
|
+
if (!window.confirm(tt('audit.resetConfirm'))) return
|
|
32
|
+
try {
|
|
33
|
+
await api.resetAudit()
|
|
34
|
+
await load()
|
|
35
|
+
} catch (err) {
|
|
36
|
+
setError(errorMessage(err))
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (loading) return <div className={css.message}>{tt('common.loading')}</div>
|
|
41
|
+
if (audit === null) return <div className={css.error}>{error}</div>
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className={css.tabBody}>
|
|
45
|
+
<div className={css.toolbar}>
|
|
46
|
+
<button type="button" className={css.button} onClick={() => { void load() }}>{tt('common.refresh')}</button>
|
|
47
|
+
<button type="button" className={css.button + ' ' + css.dangerButton} onClick={() => { void reset() }}>{tt('audit.reset')}</button>
|
|
48
|
+
</div>
|
|
49
|
+
{error !== '' && <div className={css.error}>{error}</div>}
|
|
50
|
+
<div className={css.cards}>
|
|
51
|
+
<div className={css.card}><div className={css.cardLabel}>{tt('audit.totalCalls')}</div><div className={css.cardValue}>{audit.totalCalls}</div></div>
|
|
52
|
+
<div className={css.card}><div className={css.cardLabel}>{tt('audit.inputTokens')}</div><div className={css.cardValue}>{audit.totalInputTokens}</div></div>
|
|
53
|
+
<div className={css.card}><div className={css.cardLabel}>{tt('audit.outputTokens')}</div><div className={css.cardValue}>{audit.totalOutputTokens}</div></div>
|
|
54
|
+
<div className={css.card}><div className={css.cardLabel}>{tt('audit.totalCost')}</div><div className={css.cardValue}>{audit.totalCost.toFixed(4)}</div></div>
|
|
55
|
+
<div className={css.card}><div className={css.cardLabel}>{tt('audit.monthCost')}</div><div className={css.cardValue}>{audit.monthCost.toFixed(4)}</div></div>
|
|
56
|
+
<div className={css.card}><div className={css.cardLabel}>{tt('audit.budget')}</div><div className={css.cardValue}>{audit.monthlyBudget.toFixed(2)}</div></div>
|
|
57
|
+
</div>
|
|
58
|
+
<div className={css.tableWrap}>
|
|
59
|
+
<table className={css.table}>
|
|
60
|
+
<thead>
|
|
61
|
+
<tr>
|
|
62
|
+
<th>{tt('audit.col.time')}</th>
|
|
63
|
+
<th>{tt('audit.col.server')}</th>
|
|
64
|
+
<th>{tt('audit.col.tool')}</th>
|
|
65
|
+
<th>{tt('audit.col.tokens')}</th>
|
|
66
|
+
<th>{tt('audit.col.cost')}</th>
|
|
67
|
+
<th>{tt('audit.col.status')}</th>
|
|
68
|
+
</tr>
|
|
69
|
+
</thead>
|
|
70
|
+
<tbody>
|
|
71
|
+
{audit.recent.length === 0 && <tr><td colSpan={6}>{tt('audit.empty')}</td></tr>}
|
|
72
|
+
{audit.recent.map(row => (
|
|
73
|
+
<tr key={row.id}>
|
|
74
|
+
<td>{new Date(row.ts).toLocaleString()}</td>
|
|
75
|
+
<td>{row.server}</td>
|
|
76
|
+
<td>{row.tool}</td>
|
|
77
|
+
<td>{row.inputTokens + row.outputTokens}</td>
|
|
78
|
+
<td>{row.cost.toFixed(4)}</td>
|
|
79
|
+
<td>{row.ok ? <span className={css.badge + ' ' + css.badgeOk}>{tt('audit.ok')}</span> : <span className={css.badge + ' ' + css.badgeFail}>{tt('audit.fail')}</span>}</td>
|
|
80
|
+
</tr>
|
|
81
|
+
))}
|
|
82
|
+
</tbody>
|
|
83
|
+
</table>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config tab: role, billing rates, and a generic editor for every MCP server
|
|
3
|
+
* in config.servers (stdio and streamable-http), with one-click tests.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useEffect, useState, type ReactNode } from 'react'
|
|
7
|
+
import type { RezApi } from '../api.ts'
|
|
8
|
+
import type { RezMcpServerSpec, RezPublicConfig, RezRoleId, RezTestResult } from '../../protocol.ts'
|
|
9
|
+
import { errorMessage, tt } from './helpers.ts'
|
|
10
|
+
import css from './panel.module.css'
|
|
11
|
+
|
|
12
|
+
const ROLES: Array<{ value: RezRoleId; label: string }> = [
|
|
13
|
+
{ value: 'all', label: 'role.all' },
|
|
14
|
+
{ value: 'engineer', label: 'role.engineer' },
|
|
15
|
+
{ value: 'sales', label: 'role.sales' },
|
|
16
|
+
{ value: 'operations', label: 'role.operations' },
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
function Field({ label, children }: { label: string; children: ReactNode }) {
|
|
20
|
+
return (
|
|
21
|
+
<div className={css.field}>
|
|
22
|
+
<span className={css.label}>{label}</span>
|
|
23
|
+
{children}
|
|
24
|
+
</div>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function Section({ title, children }: { title: string; children: ReactNode }) {
|
|
29
|
+
return (
|
|
30
|
+
<section className={css.section}>
|
|
31
|
+
<h3 className={css.sectionTitle}>{title}</h3>
|
|
32
|
+
{children}
|
|
33
|
+
</section>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseRecord(text: string): Record<string, string> {
|
|
38
|
+
const value: unknown = JSON.parse(text)
|
|
39
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('expected a JSON object')
|
|
40
|
+
const out: Record<string, string> = {}
|
|
41
|
+
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
|
42
|
+
out[key] = String(item)
|
|
43
|
+
}
|
|
44
|
+
return out
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function ConfigTab({ api }: { api: RezApi }) {
|
|
48
|
+
const [config, setConfig] = useState<RezPublicConfig | null>(null)
|
|
49
|
+
const [loading, setLoading] = useState(true)
|
|
50
|
+
const [saving, setSaving] = useState(false)
|
|
51
|
+
const [testing, setTesting] = useState(false)
|
|
52
|
+
const [message, setMessage] = useState('')
|
|
53
|
+
const [error, setError] = useState('')
|
|
54
|
+
const [results, setResults] = useState<RezTestResult[]>([])
|
|
55
|
+
const [argsText, setArgsText] = useState<Record<string, string>>({})
|
|
56
|
+
const [envText, setEnvText] = useState<Record<string, string>>({})
|
|
57
|
+
const [headerText, setHeaderText] = useState<Record<string, string>>({})
|
|
58
|
+
|
|
59
|
+
const initTexts = (next: RezPublicConfig): void => {
|
|
60
|
+
const args: Record<string, string> = {}
|
|
61
|
+
const envs: Record<string, string> = {}
|
|
62
|
+
const headers: Record<string, string> = {}
|
|
63
|
+
for (const [id, server] of Object.entries(next.servers)) {
|
|
64
|
+
args[id] = (server.args ?? []).join('\n')
|
|
65
|
+
envs[id] = JSON.stringify(server.env ?? {}, null, 2)
|
|
66
|
+
headers[id] = JSON.stringify(server.headers ?? {}, null, 2)
|
|
67
|
+
}
|
|
68
|
+
setArgsText(args)
|
|
69
|
+
setEnvText(envs)
|
|
70
|
+
setHeaderText(headers)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const load = async (): Promise<void> => {
|
|
74
|
+
try {
|
|
75
|
+
const next = await api.getConfig()
|
|
76
|
+
setConfig(next)
|
|
77
|
+
initTexts(next)
|
|
78
|
+
} catch (err) {
|
|
79
|
+
setError(errorMessage(err))
|
|
80
|
+
} finally {
|
|
81
|
+
setLoading(false)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
useEffect(() => { void load() }, [])
|
|
86
|
+
|
|
87
|
+
if (loading) return <div className={css.message}>{tt('common.loading')}</div>
|
|
88
|
+
if (config === null) return <div className={css.error}>{error}</div>
|
|
89
|
+
|
|
90
|
+
const setTop = (key: 'enabled' | 'role', value: boolean | RezRoleId): void => {
|
|
91
|
+
setConfig(prev => prev === null ? prev : { ...prev, [key]: value })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const updateServer = (id: string, patch: Partial<RezMcpServerSpec>): void => {
|
|
95
|
+
setConfig(prev => prev === null ? prev : {
|
|
96
|
+
...prev,
|
|
97
|
+
servers: { ...prev.servers, [id]: { ...prev.servers[id], ...patch } },
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const save = async (): Promise<void> => {
|
|
102
|
+
setSaving(true)
|
|
103
|
+
setMessage('')
|
|
104
|
+
setError('')
|
|
105
|
+
try {
|
|
106
|
+
const saved = await api.saveConfig(config)
|
|
107
|
+
setConfig(saved)
|
|
108
|
+
initTexts(saved)
|
|
109
|
+
setMessage(tt('config.saved'))
|
|
110
|
+
} catch (err) {
|
|
111
|
+
setError(errorMessage(err))
|
|
112
|
+
} finally {
|
|
113
|
+
setSaving(false)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const test = async (): Promise<void> => {
|
|
118
|
+
setTesting(true)
|
|
119
|
+
setMessage('')
|
|
120
|
+
setError('')
|
|
121
|
+
setResults([])
|
|
122
|
+
try {
|
|
123
|
+
setResults(await api.test())
|
|
124
|
+
} catch (err) {
|
|
125
|
+
setError(errorMessage(err))
|
|
126
|
+
} finally {
|
|
127
|
+
setTesting(false)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const renderResult = (result: RezTestResult): string => {
|
|
132
|
+
if (result.ok) return tt('config.testOk', { tools: result.toolCount ?? 0, latency: result.latencyMs ?? 0 })
|
|
133
|
+
return tt('config.testFail', { error: result.error ?? '' })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const commitArgs = (id: string): void => {
|
|
137
|
+
updateServer(id, { args: argsText[id] === '' ? [] : argsText[id].split('\n').map(line => line.trim()).filter(Boolean) })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const commitRecord = (id: string, kind: 'env' | 'headers'): void => {
|
|
141
|
+
const text = kind === 'env' ? envText[id] : headerText[id]
|
|
142
|
+
try {
|
|
143
|
+
const record = parseRecord(text ?? '{}')
|
|
144
|
+
updateServer(id, kind === 'env' ? { env: record } : { headers: record })
|
|
145
|
+
setError('')
|
|
146
|
+
} catch (err) {
|
|
147
|
+
setError(tt('config.jsonInvalid', { error: errorMessage(err) }))
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<div className={css.tabBody}>
|
|
153
|
+
<Section title={tt('config.role')}>
|
|
154
|
+
<Field label={tt('config.role')}>
|
|
155
|
+
<select className={css.select} value={config.role} onChange={event => { setTop('role', event.target.value as RezRoleId) }}>
|
|
156
|
+
{ROLES.map(role => <option key={role.value} value={role.value}>{tt(role.label as never)}</option>)}
|
|
157
|
+
</select>
|
|
158
|
+
</Field>
|
|
159
|
+
<Field label={tt('config.enabled')}>
|
|
160
|
+
<input className={css.checkbox} type="checkbox" checked={config.enabled} onChange={event => { setTop('enabled', event.target.checked) }} />
|
|
161
|
+
</Field>
|
|
162
|
+
</Section>
|
|
163
|
+
|
|
164
|
+
{Object.entries(config.servers).map(([id, server]) => (
|
|
165
|
+
<Section key={id} title={id}>
|
|
166
|
+
<Field label={tt('config.enabled')}>
|
|
167
|
+
<input className={css.checkbox} type="checkbox" checked={server.enabled} onChange={event => { updateServer(id, { enabled: event.target.checked }) }} />
|
|
168
|
+
</Field>
|
|
169
|
+
<Field label={tt('config.transport')}>
|
|
170
|
+
<select className={css.select} value={server.transport} onChange={event => { updateServer(id, { transport: event.target.value as RezMcpServerSpec['transport'] }) }}>
|
|
171
|
+
<option value="stdio">stdio</option>
|
|
172
|
+
<option value="streamable-http">streamable-http</option>
|
|
173
|
+
</select>
|
|
174
|
+
</Field>
|
|
175
|
+
{server.transport === 'stdio' ? (
|
|
176
|
+
<>
|
|
177
|
+
<Field label={tt('config.command')}>
|
|
178
|
+
<input className={css.input} value={server.command ?? ''} onChange={event => { updateServer(id, { command: event.target.value }) }} />
|
|
179
|
+
</Field>
|
|
180
|
+
<Field label={tt('config.args')}>
|
|
181
|
+
<textarea className={css.input} rows={3} value={argsText[id] ?? ''} onChange={event => { setArgsText(prev => ({ ...prev, [id]: event.target.value })) }} onBlur={() => { commitArgs(id) }} />
|
|
182
|
+
</Field>
|
|
183
|
+
<Field label={tt('config.env')}>
|
|
184
|
+
<textarea className={css.input} rows={4} value={envText[id] ?? '{}'} onChange={event => { setEnvText(prev => ({ ...prev, [id]: event.target.value })) }} onBlur={() => { commitRecord(id, 'env') }} />
|
|
185
|
+
</Field>
|
|
186
|
+
</>
|
|
187
|
+
) : (
|
|
188
|
+
<>
|
|
189
|
+
<Field label={tt('config.url')}>
|
|
190
|
+
<input className={css.input} value={server.url ?? ''} onChange={event => { updateServer(id, { url: event.target.value }) }} />
|
|
191
|
+
</Field>
|
|
192
|
+
<Field label={tt('config.headers')}>
|
|
193
|
+
<textarea className={css.input} rows={4} value={headerText[id] ?? '{}'} onChange={event => { setHeaderText(prev => ({ ...prev, [id]: event.target.value })) }} onBlur={() => { commitRecord(id, 'headers') }} />
|
|
194
|
+
</Field>
|
|
195
|
+
</>
|
|
196
|
+
)}
|
|
197
|
+
</Section>
|
|
198
|
+
))}
|
|
199
|
+
|
|
200
|
+
<Section title={tt('config.billing')}>
|
|
201
|
+
<Field label={tt('config.billing.input')}>
|
|
202
|
+
<input className={css.input} type="number" step="0.0001" value={config.billing.inputCostPer1k} onChange={event => { setConfig(prev => prev === null ? prev : { ...prev, billing: { ...prev.billing, inputCostPer1k: Number(event.target.value) } }) }} />
|
|
203
|
+
</Field>
|
|
204
|
+
<Field label={tt('config.billing.output')}>
|
|
205
|
+
<input className={css.input} type="number" step="0.0001" value={config.billing.outputCostPer1k} onChange={event => { setConfig(prev => prev === null ? prev : { ...prev, billing: { ...prev.billing, outputCostPer1k: Number(event.target.value) } }) }} />
|
|
206
|
+
</Field>
|
|
207
|
+
<Field label={tt('config.billing.budget')}>
|
|
208
|
+
<input className={css.input} type="number" step="0.01" value={config.billing.monthlyBudget} onChange={event => { setConfig(prev => prev === null ? prev : { ...prev, billing: { ...prev.billing, monthlyBudget: Number(event.target.value) } }) }} />
|
|
209
|
+
</Field>
|
|
210
|
+
</Section>
|
|
211
|
+
|
|
212
|
+
<div className={css.toolbar}>
|
|
213
|
+
<button type="button" className={css.button + ' ' + css.primaryButton} disabled={saving} onClick={() => { void save() }}>{tt('config.save')}</button>
|
|
214
|
+
<button type="button" className={css.button} disabled={testing} onClick={() => { void test() }}>{testing ? tt('config.testing') : tt('config.test')}</button>
|
|
215
|
+
</div>
|
|
216
|
+
{message !== '' && <div className={css.message}>{message}</div>}
|
|
217
|
+
{error !== '' && <div className={css.error}>{error}</div>}
|
|
218
|
+
{results.map(result => (
|
|
219
|
+
<div key={result.server} className={result.ok ? css.message : css.error}>{tt('config.testResult', { server: result.server, status: renderResult(result) })}</div>
|
|
220
|
+
))}
|
|
221
|
+
</div>
|
|
222
|
+
)
|
|
223
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rez Suite panel shell: header + three tabs (config/status/audit).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { useState } from 'react'
|
|
6
|
+
import type { RezApi } from '../api.ts'
|
|
7
|
+
import type { PanelController } from './controller.ts'
|
|
8
|
+
import { tt } from './helpers.ts'
|
|
9
|
+
import { AuditTab } from './AuditTab.tsx'
|
|
10
|
+
import { ConfigTab } from './ConfigTab.tsx'
|
|
11
|
+
import { StatusTab } from './StatusTab.tsx'
|
|
12
|
+
import css from './panel.module.css'
|
|
13
|
+
|
|
14
|
+
export type RezTab = 'config' | 'status' | 'audit'
|
|
15
|
+
|
|
16
|
+
export interface RezPanelProps {
|
|
17
|
+
controller: PanelController
|
|
18
|
+
api: RezApi
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const TABS: ReadonlyArray<{ id: RezTab; label: () => string }> = [
|
|
22
|
+
{ id: 'config', label: () => tt('tab.config') },
|
|
23
|
+
{ id: 'status', label: () => tt('tab.status') },
|
|
24
|
+
{ id: 'audit', label: () => tt('tab.audit') },
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
export function RezPanel({ controller, api }: RezPanelProps) {
|
|
28
|
+
const [activeTab, setActiveTab] = useState<RezTab>('config')
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className={css.panel}>
|
|
32
|
+
<div className={css.panelHeader}>
|
|
33
|
+
<h2 className={css.panelTitle}>{tt('panel.title')}</h2>
|
|
34
|
+
<button type="button" className={css.iconButton} title={tt('common.close')} aria-label={tt('common.close')} onClick={() => { controller.close() }}>x</button>
|
|
35
|
+
</div>
|
|
36
|
+
<div className={css.tabBar} role="tablist">
|
|
37
|
+
{TABS.map(tab => (
|
|
38
|
+
<button key={tab.id} type="button" role="tab" aria-selected={activeTab === tab.id} data-active={activeTab === tab.id ? '' : undefined} className={css.tab} onClick={() => { setActiveTab(tab.id) }}>
|
|
39
|
+
{tab.label()}
|
|
40
|
+
</button>
|
|
41
|
+
))}
|
|
42
|
+
</div>
|
|
43
|
+
<div className={css.panelContent}>
|
|
44
|
+
{activeTab === 'config' && <ConfigTab api={api} />}
|
|
45
|
+
{activeTab === 'status' && <StatusTab api={api} />}
|
|
46
|
+
{activeTab === 'audit' && <AuditTab api={api} />}
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
)
|
|
50
|
+
}
|