dsh-arcvista 0.0.0-stage → 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/CHANGELOG.md +8 -0
- package/LICENSE +21 -0
- package/README.md +334 -2
- package/client.js +257 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +7 -0
- package/index.js +89 -0
- package/lib/analyze/architecture.js +452 -0
- package/lib/analyze/complexity.js +470 -0
- package/lib/analyze/coverage.js +894 -0
- package/lib/analyze/deps.js +1246 -0
- package/lib/analyze/drilldown.js +440 -0
- package/lib/analyze/enrich.js +687 -0
- package/lib/analyze/evidence.js +419 -0
- package/lib/analyze/graph.js +276 -0
- package/lib/analyze/index.js +373 -0
- package/lib/analyze/knowledge.js +320 -0
- package/lib/analyze/modules.js +194 -0
- package/lib/analyze/security.js +1583 -0
- package/lib/analyze/walk.js +399 -0
- package/lib/db.js +608 -0
- package/lib/diff.js +1356 -0
- package/lib/git.js +584 -0
- package/lib/http.js +221 -0
- package/lib/jobs.js +193 -0
- package/lib/llm.js +224 -0
- package/lib/util.js +92 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +79 -4
- package/public/css/app.css +997 -0
- package/public/css/dashboard.css +726 -0
- package/public/css/diagram.css +293 -0
- package/public/css/themes.css +343 -0
- package/public/favicon.svg +10 -0
- package/public/index.html +33 -0
- package/public/js/api.js +65 -0
- package/public/js/app.js +143 -0
- package/public/js/charts.js +200 -0
- package/public/js/components/diagram.js +537 -0
- package/public/js/components/drawer.js +338 -0
- package/public/js/components/knowledge.js +535 -0
- package/public/js/components/node-drawer.js +131 -0
- package/public/js/dom.js +262 -0
- package/public/js/pages/compare.js +468 -0
- package/public/js/pages/dashboard.js +1213 -0
- package/public/js/pages/products.js +349 -0
- package/public/js/pages/settings.js +250 -0
- package/public/js/theme-boot.js +28 -0
- package/public/js/theme.js +173 -0
- package/server.js +593 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 rogue-raiders
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,335 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ArcVista · 观界
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
本地优先的架构观测台:接入一个 Git 仓库,自动生成**可下钻的系统架构图、安全架构守护清单、业务知识图谱、复杂度与测试覆盖度**,并支持**对比不同分支之间的架构漂移与风险**。
|
|
4
|
+
|
|
5
|
+
零第三方依赖,只用 Node 22 内置的 `node:http` + `node:sqlite`,数据保存在本机 SQLite;内置 6 套可切换皮肤。
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
## 为什么做它
|
|
10
|
+
|
|
11
|
+
大多数代码质量工具只告诉你"哪里有问题",但很少回答系统级的四个问题:
|
|
12
|
+
|
|
13
|
+
1. 这个系统现在**长什么样**(分层、边界、服务之间的真实调用与数据依赖);
|
|
14
|
+
2. 它有哪些**架构守护**(认证、授权、注入防护、扫描规则、CI 门禁),这些规则是否可追溯;
|
|
15
|
+
3. 它的**业务知识**如何组织(业务域、能力、实体、事件、主流程);
|
|
16
|
+
4. 换一个分支之后,架构、守护规则、依赖、复杂度和覆盖率**发生了什么变化,是否存在风险**。
|
|
17
|
+
|
|
18
|
+
ArcVista 把这些问题做成一个可以直接下钻的看板,而不是一堆静态报告。
|
|
19
|
+
|
|
20
|
+
## 作为 DSH 插件
|
|
21
|
+
|
|
22
|
+
当前仓库**根目录就是一个标准 DSH Bundle**,包名 `dsh-arcvista`。安装到 DSH profile 后,DSH 左侧会出现 **ArcVista 观界** 入口,点击即可在工作区内打开完整的 ArcVista 页面。
|
|
23
|
+
|
|
24
|
+

|
|
25
|
+
|
|
26
|
+
- **不需要单独启动 Node 服务**:ArcVista 的 API 和静态页面由 DSH Host 插件直接挂载在 `/arcvista` 路径下;
|
|
27
|
+
- **不需要配置模型**:分析使用 DSH 当前选择的默认模型(例如 `deepseek-flash`),模型调用通过 DSH 的 `llm` 服务完成;设置页会自动隐藏原来的模型 / API Key 配置;
|
|
28
|
+
- 数据仍保存在本机:默认写在 `~/.dsh/arcvista/`(SQLite、备份和仓库镜像),不会写进 `node_modules`;
|
|
29
|
+
- 面板顶部显示运行状态,右上角可以刷新或在独立浏览器窗口打开;
|
|
30
|
+
- 主题跟随 DSH 的浅色 / 深色模式(映射到 `daylight` / `obsidian`),内嵌页面隐藏了 ArcVista 原来的左侧栏;
|
|
31
|
+
- 插件随 DSH 启动而加载,无需手动维护端口或进程。
|
|
32
|
+
|
|
33
|
+
### 安装
|
|
34
|
+
|
|
35
|
+
从 GitHub 镜像安装(推荐,固定 Commit 更安全):
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
dsh plugin --profile web add https://github.com/yonking305/arc-vista.git
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
发布到 npm 后也可以直接安装:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
dsh plugin --profile web add dsh-arcvista
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
本地开发:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
dsh plugin --profile web add /absolute/path/to/arc-vista
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### 发布与商店
|
|
54
|
+
|
|
55
|
+
- 发布包名:`dsh-arcvista`
|
|
56
|
+
- 仓库:<https://gitee.com/rogue-raiders/arc-vista>
|
|
57
|
+
- 当前声明兼容 DSH:`0.2.0-rc.2`
|
|
58
|
+
- 待办:DSH-Store 目前只收录 **GitHub** 仓库,Gitee 源需要同步一个公开的 GitHub 镜像后再提交上架 Issue。
|
|
59
|
+
|
|
60
|
+
> 插件 Host 代码更新后需要重启一次 DSH 才会加载新模块;只改页面样式 / 前端逻辑时刷新页面即可。
|
|
61
|
+
|
|
62
|
+
### 权限与数据
|
|
63
|
+
|
|
64
|
+
这个插件会在 DSH Host 进程里执行以下操作,安装前请确认:
|
|
65
|
+
|
|
66
|
+
- 用 `git` 克隆 / 拉取被观测仓库,读取工作区文件,生成静态分析结果;
|
|
67
|
+
- 在 DSH profile 的 `arcvista/` 下写 SQLite、备份和任务状态;
|
|
68
|
+
- 通过 DSH 的 `llm` 服务调用当前默认模型做语义增强;
|
|
69
|
+
- 在 DSH Web 服务器注册 `/arcvista` 路由,用于面板和 API。
|
|
70
|
+
|
|
71
|
+
不包含 `install` / `prepare` 等生命周期脚本,不修改 DSH 核心,不占用 `@deepseek-ai/*` 命名空间。
|
|
72
|
+
|
|
73
|
+
## 快速开始
|
|
74
|
+
|
|
75
|
+
要求:Node.js **>= 22.5**(使用内置 `node:sqlite`),本机安装 `git`。
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
cd arc-vista
|
|
79
|
+
npm start
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
打开 <http://127.0.0.1:4321>,点击「新建观测目标」,填入:
|
|
83
|
+
|
|
84
|
+
- 产品名称;
|
|
85
|
+
- Git 仓库地址(支持 `https://` / `ssh://` / `git@` / 本地绝对路径);
|
|
86
|
+
- 可选分支与描述。
|
|
87
|
+
|
|
88
|
+
创建后会先在后台克隆 / 拉取仓库,完成后进入产品的「分支分析清单」:勾选要分析的分支(可多选)后开始分析,任务会按队列依次执行,全部分析完成后进入看板。
|
|
89
|
+
|
|
90
|
+
> 不需要 `npm install`:项目没有任何运行时依赖。第一次分析需要能访问仓库地址。
|
|
91
|
+
|
|
92
|
+
## 皮肤
|
|
93
|
+
|
|
94
|
+
内置 6 套皮肤,左下角的调色盘按钮即可切换,也可以在产品设置页里预览选择:
|
|
95
|
+
|
|
96
|
+
| 皮肤 | 风格 |
|
|
97
|
+
| --- | --- |
|
|
98
|
+
| 深空观测(默认) | 墨蓝底 + 琥珀信号灯,夜航观测台气质 |
|
|
99
|
+
| 蓝图档案 | 深蓝工程图纸 + 电光青标注 |
|
|
100
|
+
| 纸本手记 | 暖纸 + 朱砂批注,浅色,适合白天阅读与投屏 |
|
|
101
|
+
| 午夜霓虹 | 洋红 + 电光蓝,高对比机房霓虹 |
|
|
102
|
+
| 苔原深林 | 墨绿 + 黄铜,低饱和长时段观色 |
|
|
103
|
+
| 日光实验室 | 冷灰 + 信号橙,浅色、干净、适合截图归档 |
|
|
104
|
+
|
|
105
|
+

|
|
106
|
+
|
|
107
|
+
皮肤只改变界面配色,不影响任何分析结果;选择保存在浏览器 `localStorage`,刷新与重新打开都会保留。架构图、知识图谱、图表和绘制的颜色都通过主题变量驱动,切换时无需重新分析或刷新页面。
|
|
108
|
+
|
|
109
|
+
## 功能
|
|
110
|
+
|
|
111
|
+
### 0. 仓库准备与分支分析清单
|
|
112
|
+
|
|
113
|
+

|
|
114
|
+
|
|
115
|
+
- 新建产品后先显示克隆 / 拉取进度,完成后自动刷新出分支清单;
|
|
116
|
+
- 每个分支显示最新提交、是否已分析、分析中 / 排队中 / 上次失败等状态;
|
|
117
|
+
- 可勾选多个未分析分支批量开始,任务按队列依次执行;
|
|
118
|
+
- 已分析分支可以直接「查看看板」或「重新分析」;
|
|
119
|
+
- 全部分析完成后自动进入看板,也可以随时从看板右上角「分析分支」打开清单。
|
|
120
|
+
|
|
121
|
+
### 1. 系统架构图(可下钻)
|
|
122
|
+
|
|
123
|
+

|
|
124
|
+
|
|
125
|
+
- 由仓库结构、构建清单、网关路由、Feign 声明、编排配置与依赖推断生成的分层架构图:客户端 → 接入层 → 服务层 → 数据层 → 基础设施 / 外部系统。
|
|
126
|
+
- 拖拽平移、滚轮缩放、节点悬停高亮依赖链路;点击节点打开节点详情(指标、复杂度驱动、上下游依赖、下钻入口)。
|
|
127
|
+
- **下钻分析**:进入单个模块的内部结构(按 Controller / Service / Domain / DTO / 事件 / 配置等职责聚合的依赖子图)、关键流程、对外接口、调用方/被依赖方、数据组件、模块风险与改进建议;支持继续追问该模块。
|
|
128
|
+
- 配置大模型后,架构的命名、业务职责、外部系统与连接会由模型基于代码证据做语义增强;没有模型时启发式结果同样完整可用。
|
|
129
|
+
|
|
130
|
+
### 2. 安全架构守护
|
|
131
|
+
|
|
132
|
+

|
|
133
|
+
|
|
134
|
+
- 19 类守护族:身份认证、授权/RBAC、输入校验、注入防护、传输加密、CORS、CSRF、限流、SAST、依赖扫描、密钥扫描、硬编码密钥、审计日志、依赖锁定、容器安全、安全响应头、安全测试等。
|
|
135
|
+
- 每项守护给出 `enforced / partial / missing` 状态、规则清单、证据文件与行号、改进建议。
|
|
136
|
+
- 解析 `semgrep`、ESLint 安全规则、CodeQL、SonarQube、gitleaks、Dependabot、Renovate、Snyk、Trivy、Dockerfile、lockfile、Spring Security / JWT / MyBatis / helmet / express-rate-limit 等真实配置。
|
|
137
|
+
- 规则清单是**可 diff 的**:分支对比会逐条列出新增和移除的规则。
|
|
138
|
+
|
|
139
|
+
### 3. 业务知识图谱
|
|
140
|
+
|
|
141
|
+

|
|
142
|
+
|
|
143
|
+
- 业务域来自服务边界;能力来自 Controller 聚合;实体来自领域模型;事件来自 Kafka / RabbitMQ 主题与监听器;数据组件来自依赖与编排配置;参与者来自客户端入口。
|
|
144
|
+
- 卡片式业务域泳道:业务能力、领域实体、事件、数据组件、参与者分别用不同类型卡片呈现;接口与领域实体默认收起避免过密,可在筛选器中展开。
|
|
145
|
+
- 普通滚动只滚动页面,`Ctrl/⌘ + 滚轮`或工具条缩放;点击节点查看业务含义,并从业务域下钻到对应代码模块。
|
|
146
|
+
- 配置大模型后自动补充业务语义、业务能力、主业务流程(如"下单 → 支付 → 扣减库存 → 发货签收")与跨域事实。
|
|
147
|
+
|
|
148
|
+
### 4. 复杂度
|
|
149
|
+
|
|
150
|
+
- 系统复杂度评分 + 模块级矩阵:LOC、文件数、平均/最大文件行数、分支密度、扇入/扇出、Git 变更频率。
|
|
151
|
+
- 识别复杂度热点、给出可执行的治理建议;前端 CSS/SCSS 等样式文件不计入复杂度评分。
|
|
152
|
+
- 雷达图展示系统形态(规模、文件粒度、耦合、变更热度、测试缺口)。
|
|
153
|
+
|
|
154
|
+
### 5. 测试覆盖
|
|
155
|
+
|
|
156
|
+
- 优先解析真实覆盖率产物:`coverage/coverage-summary.json`、`lcov.info`、JaCoCo `jacoco.xml`、Cobertura `coverage.xml`、`.nyc_output`。
|
|
157
|
+
- 没有产物时使用明确标注的**估算值**(测试/源码 LOC 比、测试文件比、用例数、零测试模块),并在界面和风险条目中说明局限。
|
|
158
|
+
- 展示测试用例数、测试文件、模块覆盖、未覆盖模块与大文件、测试缺口清单。
|
|
159
|
+
|
|
160
|
+
### 6. 分支对比与风险判断
|
|
161
|
+
|
|
162
|
+

|
|
163
|
+
|
|
164
|
+
选择任意两个快照(通常来自两个分支)进行对比:
|
|
165
|
+
|
|
166
|
+
- **结论**:整体改善 / 基本稳定 / 需要关注 / 存在风险,附质量变化分与增量风险分。
|
|
167
|
+
- **指标变化**:安全分、守护规则数、系统复杂度、行覆盖率、测试用例数、代码规模、模块数、依赖数、业务 API 数、风险代码模式。
|
|
168
|
+
- **安全守护 diff**:新增/移除守护、状态变化、规则集逐条增删。
|
|
169
|
+
- **依赖 diff**:新增/移除依赖、版本升级(major 标记)、降级。
|
|
170
|
+
- **复杂度 / 覆盖率 diff**:模块级升降排名、新增未测试文件、测试用例减少。
|
|
171
|
+
- **架构与业务 diff**:架构节点增减、业务域/实体/接口/事件变化。
|
|
172
|
+
- **风险清单**:只把本次变更引入的风险计入结论;目标分支的历史高危单独列为"存量风险",避免把老问题算到新分支头上。
|
|
173
|
+
|
|
174
|
+
## 配置大模型
|
|
175
|
+
|
|
176
|
+
支持所有 OpenAI 兼容接口(DeepSeek / OpenAI / Moonshot / 通义千问 / 本地 Ollama 等)。
|
|
177
|
+
|
|
178
|
+
在页面左下角进入「模型设置」,填写 Base URL、模型名与 API Key,可一键测试连接。也可以用环境变量启动,环境变量优先级更高:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
ARCVISTA_LLM_API_KEY=sk-xxx \
|
|
182
|
+
ARCVISTA_LLM_BASE_URL=https://api.deepseek.com/v1 \
|
|
183
|
+
ARCVISTA_LLM_MODEL=deepseek-chat \
|
|
184
|
+
npm start
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
模型会被用于:
|
|
188
|
+
|
|
189
|
+
- 架构图的业务命名、分层语义、外部系统补全;
|
|
190
|
+
- 业务知识图谱的业务域/能力/流程;
|
|
191
|
+
- 基于指标的架构风险与治理建议;
|
|
192
|
+
- 模块下钻的职责说明、内部结构与追问回答。
|
|
193
|
+
|
|
194
|
+
**没有配置 Key 也能完整使用**:架构、依赖、安全守护、复杂度、覆盖率、知识图谱全部由静态分析引擎生成,风险规则照常运行。快照会记录当次使用的引擎(`LLM 语义增强` / `启发式`)。
|
|
195
|
+
|
|
196
|
+
## 工作方式
|
|
197
|
+
|
|
198
|
+
```
|
|
199
|
+
Git 仓库 ──► 本地镜像(bare)──► 按提交检出 worktree
|
|
200
|
+
│
|
|
201
|
+
┌─────────────────────────────┼──────────────────────────────┐
|
|
202
|
+
▼ ▼ ▼
|
|
203
|
+
文件/模块/依赖图 安全守护扫描 覆盖率产物解析
|
|
204
|
+
│ │ │
|
|
205
|
+
├──────────────┬──────────────┴──────────────┬───────────────┤
|
|
206
|
+
▼ ▼ ▼ ▼
|
|
207
|
+
架构模型 知识图谱 复杂度 风险规则
|
|
208
|
+
└──────────────┴──────────► 可选 LLM 语义增强 ◄─────────────┘
|
|
209
|
+
│
|
|
210
|
+
▼
|
|
211
|
+
快照(SQLite)+ 分支 diff
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
- 仓库以 bare mirror 形式缓存在 `data/repos/`,按提交创建临时 worktree,分析完成后只保留最近用到的 worktree;**不会修改你的仓库**。
|
|
215
|
+
- 每次分析生成一个不可变快照,包含完整分析 JSON、指标摘要、提交信息与引擎信息。
|
|
216
|
+
- 分支对比基于快照计算,不依赖实时重分析。
|
|
217
|
+
|
|
218
|
+
## Git 超时与断点续传
|
|
219
|
+
|
|
220
|
+
大仓库首次克隆可能很慢。克隆、拉取与检出遇到**超时或网络错误会自动重试**(默认共 4 次,退避等待 2s / 4s / 6s):
|
|
221
|
+
|
|
222
|
+
- 克隆中断时会保留已经下载的数据,下一次尝试自动改为 `git fetch` **断点续传**,而不是从头再克隆;
|
|
223
|
+
- 记录每次重试的原因与次数,可在任务日志中看到("克隆失败(操作超时),2.0s 后自动重试(第 2/4 次)");
|
|
224
|
+
- 网络长时间不可用导致最终失败时,任务卡片会提示可点「重试分析」继续续传,已下载的镜像不会丢弃;
|
|
225
|
+
- 克隆、拉取、检出的超时时间与重试次数都可以用下方环境变量调整,例如大仓库可以调大 `ARCVISTA_GIT_CLONE_TIMEOUT_MS`。
|
|
226
|
+
|
|
227
|
+
## 数据、备份与隐私
|
|
228
|
+
|
|
229
|
+
- 所有数据(产品、快照、下钻缓存、设置)保存在 `data/arcvista.db`(SQLite)。
|
|
230
|
+
- 仓库镜像与检出代码在 `data/repos/`、`data/worktrees/`,可随时删除,重新分析会重建。
|
|
231
|
+
- **自动备份**:启动时、每次分析完成以及删除产品前,会把 SQLite 快照写入 项目根目录的 `backups/`(默认保留 12 份,文件权限 600;可用 `ARCVISTA_BACKUP_DIR` 修改位置,`ARCVISTA_BACKUP_KEEP` 修改保留数量)。
|
|
232
|
+
- **自动恢复**:如果 `data/arcvista.db` 缺失或无法打开,启动时会自动从最近一份包含产品的备份恢复,并在启动日志和设置页提示恢复来源。
|
|
233
|
+
- 设置页「当前状态」可以看到数据目录、备份目录和最近备份时间,也可以点「立即备份数据」手动备份。
|
|
234
|
+
- 仓库镜像体积可能较大;备份只包含数据库(产品、快照、设置),不会重复备份仓库镜像。
|
|
235
|
+
- API Key 只写入本地 SQLite(也会进入上述备份文件,文件权限 600,仅保存在本机),接口返回时脱敏;服务仅监听 `127.0.0.1`。
|
|
236
|
+
- 只有配置大模型并执行分析时,才会把**仓库证据摘要**(模块、依赖、路由、配置、指标等,不含完整源码)发送给你配置的模型服务。
|
|
237
|
+
|
|
238
|
+
## 环境变量
|
|
239
|
+
|
|
240
|
+
| 变量 | 默认值 | 说明 |
|
|
241
|
+
| --- | --- | --- |
|
|
242
|
+
| `PORT` | `4321` | 服务端口 |
|
|
243
|
+
| `HOST` | `127.0.0.1` | 监听地址 |
|
|
244
|
+
| `ARCVISTA_DATA` | `./data` | 数据目录 |
|
|
245
|
+
| `ARCVISTA_BACKUP_DIR` | `./backups`(data 的同级目录) | 数据库自动备份目录 |
|
|
246
|
+
| `ARCVISTA_BACKUP_KEEP` | `12` | 备份保留份数 |
|
|
247
|
+
| `ARCVISTA_NO_AUTO_RESTORE` | — | 设为 `1` 时禁止数据库缺失时自动恢复 |
|
|
248
|
+
| `ARCVISTA_GIT_RETRIES` | `4` | 克隆/拉取/检出失败时的总尝试次数 |
|
|
249
|
+
| `ARCVISTA_GIT_RETRY_DELAY_MS` | `2000` | Git 重试的基础退避时间(毫秒) |
|
|
250
|
+
| `ARCVISTA_GIT_CLONE_TIMEOUT_MS` | `900000` | 单次克隆超时(默认 15 分钟) |
|
|
251
|
+
| `ARCVISTA_GIT_FETCH_TIMEOUT_MS` | `300000` | 单次拉取超时(默认 5 分钟) |
|
|
252
|
+
| `ARCVISTA_GIT_WORKTREE_TIMEOUT_MS` | `300000` | 单次检出超时(默认 5 分钟) |
|
|
253
|
+
| `ARCVISTA_LLM_API_KEY` | — | 大模型 API Key(优先于页面设置) |
|
|
254
|
+
| `ARCVISTA_LLM_BASE_URL` | — | 大模型 Base URL |
|
|
255
|
+
| `ARCVISTA_LLM_MODEL` | — | 模型名 |
|
|
256
|
+
| `ARCVISTA_DEBUG` | — | 打印异常堆栈 |
|
|
257
|
+
|
|
258
|
+
## API 一览
|
|
259
|
+
|
|
260
|
+
| 方法 | 路径 | 说明 |
|
|
261
|
+
| --- | --- | --- |
|
|
262
|
+
| `GET` | `/api/products` | 产品列表(含最新快照与任务状态) |
|
|
263
|
+
| `POST` | `/api/products` | 创建产品并自动开始分析 |
|
|
264
|
+
| `GET` | `/api/products/:id` | 产品详情、快照、分支、任务 |
|
|
265
|
+
| `POST` | `/api/products/:id/analyze` | 分析分支(`branch`、`force`) |
|
|
266
|
+
| `GET` | `/api/products/:id/snapshots/:sid` | 快照完整分析结果 |
|
|
267
|
+
| `POST` | `/api/products/:id/modules/analyze` | 模块下钻(异步任务) |
|
|
268
|
+
| `GET` | `/api/products/:id/compare` | 分支/快照对比 |
|
|
269
|
+
| `GET` | `/api/jobs/:id` | 任务进度与日志 |
|
|
270
|
+
| `GET/PUT` | `/api/settings` | 模型配置 |
|
|
271
|
+
| `POST` | `/api/settings/test` | 测试模型连接 |
|
|
272
|
+
|
|
273
|
+
## 目录结构
|
|
274
|
+
|
|
275
|
+
```
|
|
276
|
+
arcvista/
|
|
277
|
+
├── server.js # HTTP 服务与 API 路由
|
|
278
|
+
├── lib/
|
|
279
|
+
│ ├── analyze/ # 静态分析引擎
|
|
280
|
+
│ │ ├── walk.js # 文件扫描(排除构建产物/依赖目录)
|
|
281
|
+
│ │ ├── modules.js # 模块边界识别
|
|
282
|
+
│ │ ├── graph.js # 导入/依赖图
|
|
283
|
+
│ │ ├── deps.js # 构建清单依赖解析(npm/maven/gradle/pip/go)
|
|
284
|
+
│ │ ├── security.js # 安全架构守护
|
|
285
|
+
│ │ ├── coverage.js # 覆盖率产物解析与估算
|
|
286
|
+
│ │ ├── complexity.js # 复杂度
|
|
287
|
+
│ │ ├── architecture.js # 架构模型
|
|
288
|
+
│ │ ├── knowledge.js # 业务知识图谱
|
|
289
|
+
│ │ ├── evidence.js # 路由/实体/事件/Feign/编排证据抽取
|
|
290
|
+
│ │ ├── enrich.js # LLM 语义增强与启发式风险
|
|
291
|
+
│ │ ├── drilldown.js # 模块下钻
|
|
292
|
+
│ │ └── index.js # 分析编排
|
|
293
|
+
│ ├── diff.js # 快照对比与风险规则
|
|
294
|
+
│ ├── git.js # mirror / worktree / 分支 / churn
|
|
295
|
+
│ ├── db.js # SQLite
|
|
296
|
+
│ ├── jobs.js # 后台任务
|
|
297
|
+
│ ├── llm.js # OpenAI 兼容客户端
|
|
298
|
+
│ └── http.js # 极小 HTTP 框架
|
|
299
|
+
├── public/ # 零构建前端(原生 ES Modules + SVG)
|
|
300
|
+
│ ├── css/
|
|
301
|
+
│ └── js/
|
|
302
|
+
├── docs/
|
|
303
|
+
│ ├── analyzer-contract.md # 内部分析器契约
|
|
304
|
+
│ └── screenshots/
|
|
305
|
+
├── tests/ # node:test 冒烟测试
|
|
306
|
+
└── data/ # 运行时生成(gitignore)
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
## 开发与测试
|
|
310
|
+
|
|
311
|
+
```bash
|
|
312
|
+
npm test # 使用 node:test 运行分析器与 diff 的冒烟测试
|
|
313
|
+
npm run dev # node --watch 热重启
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
前端是原生 ES Modules + SVG,没有构建步骤;修改 `public/` 后刷新即可。
|
|
317
|
+
|
|
318
|
+
## 已知边界
|
|
319
|
+
|
|
320
|
+
- 覆盖率优先使用仓库中的真实产物;没有产物时是**估算值**,只能用于判断覆盖明显偏低或缺失,不能当作真实覆盖率。
|
|
321
|
+
- 安全守护与风险模式是启发式识别,结论会附证据路径,但仍建议人工复核。
|
|
322
|
+
- 架构中的服务间调用来自网关路由、Feign 声明、导入关系与配置推断;运行期的动态调用(如反射、消息路由)可能无法完全还原。
|
|
323
|
+
- 仓库扫描有文件数与单文件大小上限(默认 2 万文件 / 1.2MB),超大 monorepo 可调整 `scanRepo` 参数。
|
|
324
|
+
- 私有仓库需要本机 git 凭据(SSH key 或 credential helper)可用。
|
|
325
|
+
- 分支对比的语义依赖守护规则 ID、模块路径、API 路径的稳定性;大范围重命名会被视为删除 + 新增。
|
|
326
|
+
- LLM 增强需要网络与模型费用;失败会自动回退到启发式分析并在快照中记录提示。
|
|
327
|
+
|
|
328
|
+
## 与 Archify 的关系
|
|
329
|
+
|
|
330
|
+
架构图能力借鉴了 [Archify](../../.agents/skills/archify) 的分层表达与交互思路(分层、边界、图例、下钻),但 ArcVista 把它放进了持续观测的产品看板:仓库即数据源、快照可对比、结论可追溯,并增加了安全守护、知识图谱、复杂度、覆盖率与分支风险判断。
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
## License
|
|
334
|
+
|
|
335
|
+
MIT
|
package/client.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/* ArcVista workspace panel: sidebar entry + embedded local ArcVista application. */
|
|
2
|
+
window.__ModuleLoader__.load({
|
|
3
|
+
id: 'dsh-arcvista',
|
|
4
|
+
factory(require) {
|
|
5
|
+
const React = require('react');
|
|
6
|
+
const h = React.createElement;
|
|
7
|
+
const { useEffect, useRef, useState } = React;
|
|
8
|
+
|
|
9
|
+
const PANEL_ID = 'arcvista';
|
|
10
|
+
const ARCVISTA_URL = '/arcvista';
|
|
11
|
+
|
|
12
|
+
const zh = {
|
|
13
|
+
panel: 'ArcVista 架构观测台',
|
|
14
|
+
title: 'ArcVista',
|
|
15
|
+
connected: '已连接',
|
|
16
|
+
checking: '检测中',
|
|
17
|
+
offline: '未连接',
|
|
18
|
+
reload: '刷新',
|
|
19
|
+
open: '在浏览器打开',
|
|
20
|
+
loading: '正在连接 ArcVista…',
|
|
21
|
+
offlineTitle: 'ArcVista 插件后端未就绪',
|
|
22
|
+
offlineHint: 'ArcVista 现在运行在 DSH 进程内,不需要启动本地 Node。如果看到这个状态,通常需要重启 DSH 让新版本插件加载。',
|
|
23
|
+
command: '重启 DSH 以加载 ArcVista 插件',
|
|
24
|
+
retry: '重新检测',
|
|
25
|
+
tip: '也可以在浏览器中打开完整窗口。',
|
|
26
|
+
};
|
|
27
|
+
const en = {
|
|
28
|
+
panel: 'ArcVista Observatory',
|
|
29
|
+
title: 'ArcVista',
|
|
30
|
+
connected: 'Connected',
|
|
31
|
+
checking: 'Checking',
|
|
32
|
+
offline: 'Offline',
|
|
33
|
+
reload: 'Reload',
|
|
34
|
+
open: 'Open in browser',
|
|
35
|
+
loading: 'Connecting to ArcVista…',
|
|
36
|
+
offlineTitle: 'ArcVista plugin backend is not ready',
|
|
37
|
+
offlineHint: 'ArcVista now runs inside the DSH host process. If you see this state, restart DSH to load the updated plugin.',
|
|
38
|
+
command: 'Restart DSH to load the ArcVista plugin',
|
|
39
|
+
retry: 'Check again',
|
|
40
|
+
tip: 'You can also open the full window in your browser.',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const CSS = `
|
|
44
|
+
.as-root { height: 100%; min-height: 0; display: flex; flex-direction: column; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); font-size: 13px; }
|
|
45
|
+
.as-head { flex: none; height: 46px; display: flex; align-items: center; gap: 10px; padding: 0 14px; border-bottom: .5px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-base); }
|
|
46
|
+
.as-brand { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
47
|
+
.as-brand-icon { color: var(--dsw-alias-brand-primary); display: flex; flex: none; }
|
|
48
|
+
.as-title { font-size: 13px; font-weight: 600; letter-spacing: .01em; color: var(--dsw-alias-label-primary); white-space: nowrap; }
|
|
49
|
+
.as-status { display: inline-flex; align-items: center; gap: 5px; height: 22px; padding: 0 8px; border-radius: 20px; color: var(--dsw-alias-label-tertiary); font-size: 11px; white-space: nowrap; }
|
|
50
|
+
.as-status i { width: 6px; height: 6px; border-radius: 50%; background: var(--dsw-alias-state-idle-primary); display: block; }
|
|
51
|
+
.as-status--online { color: var(--dsw-alias-state-success-primary); }
|
|
52
|
+
.as-status--online i { background: var(--dsw-alias-state-success-primary); }
|
|
53
|
+
.as-status--checking i { background: var(--dsw-alias-state-warn-primary); }
|
|
54
|
+
.as-status--offline { color: var(--dsw-alias-state-error-primary); }
|
|
55
|
+
.as-status--offline i { background: var(--dsw-alias-state-error-primary); }
|
|
56
|
+
.as-spacer { flex: 1; min-width: 0; }
|
|
57
|
+
.as-head-btn { height: 28px; padding: 0 10px; border-radius: 7px; border: .5px solid var(--dsw-alias-border-l2); background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; font-size: 12px; cursor: pointer; white-space: nowrap; }
|
|
58
|
+
.as-head-btn:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
|
|
59
|
+
.as-body { position: relative; flex: 1; min-height: 0; background: var(--dsw-alias-bg-layer-1); }
|
|
60
|
+
.as-frame { width: 100%; height: 100%; border: 0; display: block; background: var(--dsw-alias-bg-base); }
|
|
61
|
+
.as-loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; color: var(--dsw-alias-label-tertiary); font-size: 12px; background: var(--dsw-alias-bg-base); }
|
|
62
|
+
.as-spinner { width: 18px; height: 18px; border: 2px solid var(--dsw-alias-border-l2); border-top-color: var(--dsw-alias-brand-primary); border-radius: 50%; animation: as-spin .9s linear infinite; }
|
|
63
|
+
@keyframes as-spin { to { transform: rotate(360deg); } }
|
|
64
|
+
.as-offline { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 32px; gap: 8px; }
|
|
65
|
+
.as-offline-icon { color: var(--dsw-alias-brand-primary); opacity: .75; margin-bottom: 6px; }
|
|
66
|
+
.as-offline h2 { margin: 0; font-size: 16px; font-weight: 600; color: var(--dsw-alias-label-primary); }
|
|
67
|
+
.as-offline p { margin: 0; max-width: 440px; color: var(--dsw-alias-label-tertiary); font-size: 13px; line-height: 1.7; }
|
|
68
|
+
.as-command { margin: 8px 0 4px; padding: 10px 14px; border: .5px solid var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary); font-family: var(--font-mono, ui-monospace, monospace); font-size: 12px; }
|
|
69
|
+
.as-offline-actions { display: flex; gap: 8px; margin-top: 6px; }
|
|
70
|
+
.as-primary { height: 30px; padding: 0 14px; border: none; border-radius: 7px; background: var(--dsw-alias-brand-primary); color: var(--dsw-alias-bg-base); font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; }
|
|
71
|
+
.as-secondary { height: 30px; padding: 0 14px; border: .5px solid var(--dsw-alias-border-l2); border-radius: 7px; background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; font-size: 12px; cursor: pointer; }
|
|
72
|
+
.as-secondary:hover { background: var(--dsw-alias-interactive-bg-hover); }
|
|
73
|
+
.as-tip { color: var(--dsw-alias-label-caption); font-size: 11px; }
|
|
74
|
+
`;
|
|
75
|
+
|
|
76
|
+
function ScopeGlyph({ size = 20 }) {
|
|
77
|
+
return h(
|
|
78
|
+
'svg',
|
|
79
|
+
{ viewBox: '0 0 64 64', width: size, height: size, 'aria-hidden': true, fill: 'none' },
|
|
80
|
+
h('circle', { cx: 32, cy: 32, r: 20, stroke: 'currentColor', strokeWidth: 2.4, opacity: 0.35 }),
|
|
81
|
+
h('circle', { cx: 32, cy: 32, r: 12, stroke: 'currentColor', strokeWidth: 2.4, opacity: 0.7 }),
|
|
82
|
+
h('circle', { cx: 32, cy: 32, r: 4.2, fill: 'currentColor' }),
|
|
83
|
+
h('path', { d: 'M32 6v8M32 50v8M6 32h8M50 32h8', stroke: 'currentColor', strokeWidth: 2.2, opacity: 0.55 }),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function useThemeScheme(getTheme, themeListeners) {
|
|
88
|
+
const [scheme, setScheme] = useState(() => {
|
|
89
|
+
try {
|
|
90
|
+
return getTheme()?.active?.colorScheme || 'dark';
|
|
91
|
+
} catch {
|
|
92
|
+
return 'dark';
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
const read = () => {
|
|
97
|
+
try {
|
|
98
|
+
setScheme(getTheme()?.active?.colorScheme || 'dark');
|
|
99
|
+
} catch {
|
|
100
|
+
/* keep current */
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
themeListeners.add(read);
|
|
104
|
+
read();
|
|
105
|
+
return () => themeListeners.delete(read);
|
|
106
|
+
}, [getTheme, themeListeners]);
|
|
107
|
+
return scheme;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function ArchscopePanel({ t, getTheme, themeListeners }) {
|
|
111
|
+
const scheme = useThemeScheme(getTheme, themeListeners);
|
|
112
|
+
const archTheme = scheme === 'light' ? 'daylight' : 'obsidian';
|
|
113
|
+
const [frameKey, setFrameKey] = useState(0);
|
|
114
|
+
const [loaded, setLoaded] = useState(false);
|
|
115
|
+
const [status, setStatus] = useState('checking');
|
|
116
|
+
const frameRef = useRef(null);
|
|
117
|
+
const initialTheme = useRef(archTheme);
|
|
118
|
+
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
let alive = true;
|
|
121
|
+
const check = async () => {
|
|
122
|
+
try {
|
|
123
|
+
const response = await fetch(`${ARCVISTA_URL}/api/health`, { cache: 'no-store' });
|
|
124
|
+
if (alive) setStatus(response.ok ? 'online' : 'offline');
|
|
125
|
+
} catch {
|
|
126
|
+
if (alive) setStatus('offline');
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
check();
|
|
130
|
+
const timer = setInterval(check, 8000);
|
|
131
|
+
return () => {
|
|
132
|
+
alive = false;
|
|
133
|
+
clearInterval(timer);
|
|
134
|
+
};
|
|
135
|
+
}, [frameKey]);
|
|
136
|
+
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
const frame = frameRef.current;
|
|
139
|
+
if (!frame || !frame.contentWindow) return;
|
|
140
|
+
try {
|
|
141
|
+
frame.contentWindow.postMessage({ type: 'arcvista:theme', theme: archTheme }, window.location.origin);
|
|
142
|
+
} catch {
|
|
143
|
+
/* iframe may not be ready yet */
|
|
144
|
+
}
|
|
145
|
+
}, [archTheme, loaded, frameKey]);
|
|
146
|
+
|
|
147
|
+
const src = `${ARCVISTA_URL}/?embed=1&dsh=1&theme=${initialTheme.current}`;
|
|
148
|
+
const statusText = status === 'online' ? t('connected') : status === 'checking' ? t('checking') : t('offline');
|
|
149
|
+
|
|
150
|
+
return h(
|
|
151
|
+
'div',
|
|
152
|
+
{ className: 'as-root' },
|
|
153
|
+
h('style', null, CSS),
|
|
154
|
+
h(
|
|
155
|
+
'div',
|
|
156
|
+
{ className: 'as-head' },
|
|
157
|
+
h('div', { className: 'as-brand' }, h('span', { className: 'as-brand-icon' }, h(ScopeGlyph, { size: 18 })), h('span', { className: 'as-title' }, t('title'))),
|
|
158
|
+
h('span', { className: `as-status as-status--${status}` }, h('i'), statusText),
|
|
159
|
+
h('span', { className: 'as-spacer' }),
|
|
160
|
+
h('button', { type: 'button', className: 'as-head-btn', onClick: () => { setLoaded(false); setFrameKey((value) => value + 1); } }, t('reload')),
|
|
161
|
+
h('button', { type: 'button', className: 'as-head-btn', onClick: () => window.open(`${ARCVISTA_URL}/`, '_blank', 'noopener,noreferrer') }, t('open')),
|
|
162
|
+
),
|
|
163
|
+
h(
|
|
164
|
+
'div',
|
|
165
|
+
{ className: 'as-body' },
|
|
166
|
+
status === 'offline' && !loaded
|
|
167
|
+
? h(
|
|
168
|
+
'div',
|
|
169
|
+
{ className: 'as-offline' },
|
|
170
|
+
h('span', { className: 'as-offline-icon' }, h(ScopeGlyph, { size: 42 })),
|
|
171
|
+
h('h2', null, t('offlineTitle')),
|
|
172
|
+
h('p', null, t('offlineHint')),
|
|
173
|
+
h('div', { className: 'as-command' }, t('command')),
|
|
174
|
+
h(
|
|
175
|
+
'div',
|
|
176
|
+
{ className: 'as-offline-actions' },
|
|
177
|
+
h('button', { type: 'button', className: 'as-primary', onClick: () => setFrameKey((value) => value + 1) }, t('retry')),
|
|
178
|
+
h('button', { type: 'button', className: 'as-secondary', onClick: () => window.open(`${ARCVISTA_URL}/`, '_blank', 'noopener,noreferrer') }, t('open')),
|
|
179
|
+
),
|
|
180
|
+
h('span', { className: 'as-tip' }, t('tip')),
|
|
181
|
+
)
|
|
182
|
+
: [
|
|
183
|
+
h('iframe', {
|
|
184
|
+
key: frameKey,
|
|
185
|
+
ref: frameRef,
|
|
186
|
+
className: 'as-frame',
|
|
187
|
+
title: t('title'),
|
|
188
|
+
src,
|
|
189
|
+
onLoad: () => setLoaded(true),
|
|
190
|
+
onError: () => setStatus('offline'),
|
|
191
|
+
}),
|
|
192
|
+
!loaded ? h('div', { className: 'as-loading' }, h('span', { className: 'as-spinner' }), t('loading')) : null,
|
|
193
|
+
],
|
|
194
|
+
),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function SidebarIcon({ size = 18 }) {
|
|
199
|
+
return h(ScopeGlyph, { size });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function apply(ctx) {
|
|
203
|
+
const locale = ctx.get('locale');
|
|
204
|
+
let t = (key) => zh[key] || key;
|
|
205
|
+
if (locale) {
|
|
206
|
+
try {
|
|
207
|
+
ctx.effect(() => locale.register('arcvista', { zh, en }), 'arcvista:locale');
|
|
208
|
+
const translate = locale.bind('arcvista');
|
|
209
|
+
t = (key) => translate(key);
|
|
210
|
+
} catch {
|
|
211
|
+
/* fall back to Chinese copy */
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const theme = ctx.get('theme');
|
|
215
|
+
const themeListeners = new Set();
|
|
216
|
+
if (theme) {
|
|
217
|
+
try {
|
|
218
|
+
ctx.effect(() => ctx.on('theme/change', () => themeListeners.forEach((listener) => listener())), 'arcvista:theme');
|
|
219
|
+
} catch {
|
|
220
|
+
/* theme sync is optional */
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const getTheme = () => {
|
|
224
|
+
try {
|
|
225
|
+
return theme?.getTheme?.() || null;
|
|
226
|
+
} catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
ctx.slots.inject('main', () =>
|
|
232
|
+
ctx.slots.register(
|
|
233
|
+
{
|
|
234
|
+
name: 'main',
|
|
235
|
+
key: PANEL_ID,
|
|
236
|
+
inject: () => ({ t, getTheme, themeListeners }),
|
|
237
|
+
},
|
|
238
|
+
ArchscopePanel,
|
|
239
|
+
),
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
ctx.slots.inject('sidebar.panellist', () =>
|
|
243
|
+
ctx.slots.register(
|
|
244
|
+
{
|
|
245
|
+
name: 'sidebar.panellist',
|
|
246
|
+
id: PANEL_ID,
|
|
247
|
+
order: 30,
|
|
248
|
+
label: () => t('panel'),
|
|
249
|
+
},
|
|
250
|
+
SidebarIcon,
|
|
251
|
+
),
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return { inject: ['slots', 'locale', 'theme'], apply };
|
|
256
|
+
},
|
|
257
|
+
});
|
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
2
|
+
<rect width="64" height="64" rx="14" fill="#0a0e14"/>
|
|
3
|
+
<circle cx="32" cy="32" r="20" fill="none" stroke="#ffb054" stroke-width="2" opacity=".35"/>
|
|
4
|
+
<circle cx="32" cy="32" r="12" fill="none" stroke="#ffb054" stroke-width="2" opacity=".65"/>
|
|
5
|
+
<circle cx="32" cy="32" r="4" fill="#ffb054"/>
|
|
6
|
+
<path d="M32 6v8M32 50v8M6 32h8M50 32h8" stroke="#ffb054" stroke-width="2" opacity=".55"/>
|
|
7
|
+
</svg>
|