feat: prepare 0.3.3 worker llm agent and ui enhancements
This commit is contained in:
1 parent
d3183dbbd2
commit
1bebdce7f1
28 files changed
+1331
-978
No files matched your search
@@ -55,9 +55,10 @@ jobs:
|
||||
CGO_ENABLED: "0"
|
||||
run: |
|
||||
BIN=artex
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
[ "$GOOS" = "windows" ] && BIN=artex.exe
|
||||
go build -tags embedui -trimpath \
|
||||
-ldflags "-s -w -X main.version=${GITHUB_REF_NAME}" \
|
||||
-ldflags "-s -w -X main.version=${VERSION}" \
|
||||
-o "$BIN" ./cmd/artex
|
||||
- name: Package
|
||||
run: |
|
||||
|
||||
+84
-18
@@ -4,28 +4,93 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
暂无未发布变更。
|
||||
|
||||
- 增加全局任务模板管理,新建任务可直接加载预设描述与目标,也可将当前内容另存为模板。
|
||||
- 新建任务支持关联多个企业资产范围;企业范围采用结构化规则,覆盖域名、IP、CIDR、ICP 和企业关键词。
|
||||
- 发现页按任务分组展示漏洞并支持组内独立分页;漏洞可提交带描述的高优先级深入意图,在原任务中继续验证。
|
||||
- 对话支持重命名、置顶和取消置顶;任务与 Worker 会话支持批量暂停、继续及逐项结果反馈。
|
||||
- 会话列表展示 Main Agent、Planner 和 Worker 下一次调用的模型,并提供完整历史 Token 聚合与高亮统计。
|
||||
## [0.3.3] - 2026-08-21
|
||||
|
||||
### Changed
|
||||
### Worker
|
||||
|
||||
- 任务列表移除单独的“进入”操作,改为通过可聚焦的任务标题链接进入详情。
|
||||
- 新增企业改用与任务、LLM 一致的右侧抽屉,并移除 Logo URL 输入项。
|
||||
- 任务创建、恢复、漏洞深入和队列补位统一使用持久化并发准入与 FIFO 调度路径。
|
||||
- 任务资产、企业资产、发现任务组及组内漏洞使用服务端真实分页、稳定排序和精确总数。
|
||||
#### 新增的功能
|
||||
|
||||
### Fixed
|
||||
- 运行中的 Worker 支持单项暂停、恢复和取消;暂停保留意图、事实与漏洞,取消会在执行退出后事务性清理当前意图及其直接产物。
|
||||
- 增加 `paused` 意图状态、执行栅栏和具名终止原因,防止暂停、取消或任务删除后出现迟到黑板写入。
|
||||
- 增加可选任务并发上限;新建、恢复、漏洞深入和队列补位统一通过持久化 FIFO 准入路径。
|
||||
|
||||
- 修复 LLM 配置链耗尽错误在窄屏横向溢出,以及会话、对话头部和企业范围编辑在移动端的同类布局问题。
|
||||
- 修复跨任务同名漏洞节点可能联动编辑、删除或展开,以及删除末页数据后页码越界的问题。
|
||||
- 修复任务生命周期并发写入可能以旧快照覆盖终态、LLM 自动切换可能被旧配置快照回退的问题。
|
||||
- 修复 `timeout` 任务复活后仍被旧截止时间和收尾状态阻塞;复活后会保留时限配置并在下一次真实执行时重新计时。
|
||||
- 修复资产分页不稳定、极端页码 OFFSET 溢出、超大请求体状态码错误及部分存储层错误被吞掉的问题。
|
||||
#### 修改的功能
|
||||
|
||||
- Worker 单次运行墙钟默认时长由 600 秒调整为 1200 秒;超时任务复活后在下一次真实执行时重新计时。
|
||||
- Worker、Planner 和主 Agent 的真实执行状态统一驱动任务状态徽标,修复 Worker 运行时仍显示“空闲”的问题。
|
||||
- Worker 会话保留单项控制和当前会话 Token 汇总,模型名称移动到当前会话标题右侧展示。
|
||||
|
||||
#### 删除的功能
|
||||
|
||||
- 删除 Worker 多选、全选及批量暂停/继续界面,同时移除 Worker 批量控制 API 和 Mock 契约。
|
||||
- 删除 Worker 列表行中的 Token 徽标与 Tooltip,底层完整 Token 账本和任务聚合接口继续保留。
|
||||
|
||||
### LLM
|
||||
|
||||
#### 新增的功能
|
||||
|
||||
- 任务支持有序 LLM 配置链;明确识别供应商额度不足时自动切换下一配置,并持久化当前配置、耗尽状态和错误摘要。
|
||||
- 运行中和暂停任务支持编辑完整配置链、调整顺序和手动切换当前配置,从下一次 LLM 调用生效。
|
||||
- 自动切换、手动切换和全链耗尽写入结构化系统活动,并通过任务活动流弹出可去重提醒。
|
||||
- 增加任务角色模型解析接口,统一解析 Main Agent、Planner 和 Worker 下一次调用使用的配置与模型。
|
||||
|
||||
#### 修改的功能
|
||||
|
||||
- 目标拆解、Planner、Worker 和主 Agent 共享任务级 LLM runtime;任务显式配置链优先于角色绑定和全局配置。
|
||||
- 故障转移只响应明确的额度、余额或账单错误;普通限流、鉴权、网络、服务端和上下文错误不会错误切换。
|
||||
- LLM 配置页改为配置卡片列表和右侧抽屉编辑,保留任务级配置链,不引入 Pool 轮询、优先级和熔断界面。
|
||||
- 配置链耗尽信息支持窄屏换行;当前模型由会话列表图标改为当前会话标题旁的文字标签和完整 Tooltip。
|
||||
|
||||
#### 删除的功能
|
||||
|
||||
- 删除会话列表中的 LLM 图标式标识,避免与 Worker 状态图标混淆。
|
||||
|
||||
### UI
|
||||
|
||||
#### 新增的功能
|
||||
|
||||
- 增加全局任务模板 CRUD 和右侧管理抽屉;新建任务可加载预设描述与目标,也可将当前内容另存为模板。
|
||||
- 新建任务支持关联多个来源任务和多个企业资产范围;企业范围通过单个多行文本框自动识别域名、URL、IP、CIDR、ICP 和企业关键词。
|
||||
- 发现页按任务分组并提供组内独立分页,支持为漏洞填写描述并创建高优先级深入意图。
|
||||
- 对话支持重命名、置顶、取消置顶和删除;任务列表支持当前页批量暂停与继续。
|
||||
- 流量详情和工具执行详情改为右侧抽屉;HTTP 报文补齐 `Host` 并高亮请求行、状态码、Header、JSON 和标记语言正文。
|
||||
|
||||
#### 修改的功能
|
||||
|
||||
- 任务标题改为可聚焦详情链接;任务资产、企业资产、发现任务组和组内漏洞使用服务端真实分页、稳定排序与精确总数。
|
||||
- 新增企业改用右侧抽屉,范围录入统一为多行文本并提供实时识别、校验和类型预览。
|
||||
- 主 Agent 输入框支持自动增长、`Enter` 发送、`Shift+Enter` 换行,并避免中文输入法组合态误发送。
|
||||
- Agent 预览、任务报告和相关详情统一使用共享 Markdown 渲染;删除确认、长错误和移动端抽屉宽度统一修复。
|
||||
- 任务模板选择框以模板名称显示和搜索、以模板 ID 提交;恢复系统原始字体尺寸,应用版本统一显示为 `0.3.3`。
|
||||
- 任务总计和当前会话 Token 只高亮输入、缓存读取和输出数值;发送按钮使用更简洁的上箭头图标。
|
||||
|
||||
#### 删除的功能
|
||||
|
||||
- 删除任务卡片独立“进入”按钮,统一点击任务标题进入详情。
|
||||
- 删除仪表盘“新建任务”按钮、新增企业的 Logo URL 输入项,以及发现页顶部和任务分组中的漏洞数量 Badge。
|
||||
- 撤销全局字体增大 10% 的样式,并删除会话列表中的 Worker 选择框、模型图标和行级 Token 统计。
|
||||
|
||||
### Agent
|
||||
|
||||
#### 新增的功能
|
||||
|
||||
- 新任务可直接关联多个已有任务,实时只读继承直接来源任务的目标、事实、漏洞、已完成意图、资产范围和黑板上下文。
|
||||
- 黑板读取工具支持按需查询来源任务节点、事实、漏洞和执行轨迹;继承节点带来源标记且所有写工具拒绝修改。
|
||||
- 企业范围工具支持域名、URL、IP、CIDR、ICP 和企业关键词;关键词仅作为 Agent 范围提示,不参与资产自动归属。
|
||||
- 漏洞深入操作会在原任务创建带资产锚点和 `derived_from` 边的高优先级人工 Worker 意图。
|
||||
|
||||
#### 修改的功能
|
||||
|
||||
- 来源任务意图不进入新任务 frontier,新任务继续使用独立 exploration、执行队列、工作目录和对话历史。
|
||||
- 主 Agent 对话改为服务端活动流驱动,发送失败可恢复输入;打开会话时自动贴底并在详情懒加载后保持最后回复可见。
|
||||
- 任务暂停会终止当前 Main Agent、Planner 和 Worker 调用,但不阻止暂停期间用户主动发起新的主 Agent 编排会话。
|
||||
- 取消、关闭和流式中断统一保留真实终止原因、已生成内容、运行轮数、耗时、Token 与未返回工具调用。
|
||||
|
||||
#### 删除的功能
|
||||
|
||||
- 删除主 Agent 客户端乐观消息回显,避免暂停、失败或并发活动下出现重复消息和跨会话串流。
|
||||
|
||||
## [0.3.2] - 2026-08-20
|
||||
|
||||
@@ -68,5 +133,6 @@
|
||||
- [@Autumn-27](https://github.com/Autumn-27)
|
||||
- [@neouks](https://github.com/neouks)
|
||||
|
||||
[Unreleased]: https://github.com/Autumn-27/ARTEX/compare/v0.3.2...HEAD
|
||||
[Unreleased]: https://github.com/Autumn-27/ARTEX/compare/v0.3.3...HEAD
|
||||
[0.3.3]: https://github.com/Autumn-27/ARTEX/compare/v0.3.2...v0.3.3
|
||||
[0.3.2]: https://github.com/Autumn-27/ARTEX/compare/v0.3.1...v0.3.2
|
||||
@@ -293,14 +293,14 @@ func (t *ToolSet) insertAssets() actool.CoreTool {
|
||||
func (t *ToolSet) addCompanyScope() actool.CoreTool {
|
||||
return writeTool(
|
||||
"add_company_scope",
|
||||
"把根域名/IP/IP段(CIDR)加入某公司的【资产范围】——加入后系统自动认领所有命中的资产(现有+未来)。\n"+
|
||||
"把域名/IP/CIDR/ICP备案/企业关键词加入某公司的【资产范围】——域名、网络和ICP会自动认领命中的资产,关键词只提供给Agent作为范围提示。\n"+
|
||||
"公司名唯一:company 不存在则新建,已存在则复用(只把范围并进去)。\n"+
|
||||
"scope 一行一条,支持:根域名 / 单个 IP / CIDR 网段(如 1.2.3.0/24)。\n"+
|
||||
"scope 一行一条,系统自动识别:根域名 / URL / 单个 IP / CIDR 网段 / ICP备案 / 企业关键词。\n"+
|
||||
"务必给 reason 说明归属依据(whois/证书/ASN 等)。\n"+
|
||||
"护栏:拒绝裸 TLD 与过宽网段(IPv4<=/16、IPv6<=/32),非法行会被跳过并在 errors 返回。",
|
||||
"护栏:拒绝裸 TLD 与过宽网段(IPv4前缀需为/16-/32、IPv6前缀需为/32-/128),非法行会被跳过并在 errors 返回。",
|
||||
obj(map[string]any{
|
||||
"company": str("公司名(不存在则新建、存在则复用;名称唯一)"),
|
||||
"scope": str("资产范围,一行一条:根域名 / IP / CIDR"),
|
||||
"scope": str("资产范围,一行一条:域名 / URL / IP / CIDR / ICP备案 / 企业关键词"),
|
||||
"reason": str("归属依据(证据/来源),务必填写"),
|
||||
"logo": str("公司图标 URL(可选;仅新建公司时生效)"),
|
||||
}, "company", "scope"),
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# 可选环境变量:
|
||||
# ARTEX_TARGET_OS=linux 目标系统,默认当前系统
|
||||
# ARTEX_TARGET_ARCH=amd64 目标架构,默认当前架构
|
||||
# ARTEX_BUILD_VERSION=v0.3.0 构建版本,默认取 git describe
|
||||
# ARTEX_BUILD_VERSION=0.3.3 构建版本,默认使用当前应用版本
|
||||
# ARTEX_OUTPUT=/path/to/artex 输出路径
|
||||
# ARTEX_SKIP_NPM_CI=1 跳过 npm ci,复用现有依赖
|
||||
# ARTEX_GOSUMDB=sum.golang.org Go checksum database
|
||||
@@ -24,13 +24,7 @@ ARTEX_GOSUMDB="${ARTEX_GOSUMDB:-sum.golang.org}"
|
||||
ARTEX_TARGET_OS="${ARTEX_TARGET_OS:-$(GOSUMDB="$ARTEX_GOSUMDB" go env GOOS)}"
|
||||
ARTEX_TARGET_ARCH="${ARTEX_TARGET_ARCH:-$(GOSUMDB="$ARTEX_GOSUMDB" go env GOARCH)}"
|
||||
|
||||
if [ -z "${ARTEX_BUILD_VERSION:-}" ]; then
|
||||
if command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
ARTEX_BUILD_VERSION="$(git describe --tags --always --dirty)"
|
||||
else
|
||||
ARTEX_BUILD_VERSION="dev"
|
||||
fi
|
||||
fi
|
||||
ARTEX_BUILD_VERSION="${ARTEX_BUILD_VERSION:-0.3.3}"
|
||||
|
||||
ARTEX_BINARY_NAME="artex"
|
||||
[ "$ARTEX_TARGET_OS" = "windows" ] && ARTEX_BINARY_NAME="artex.exe"
|
||||
|
||||
+3
-3
@@ -21,9 +21,9 @@ import (
|
||||
"github.com/Autumn-27/artex/server"
|
||||
)
|
||||
|
||||
// version is the build version, injected at release time via
|
||||
// -ldflags "-X main.version=<tag>". Defaults to "dev" for local builds.
|
||||
var version = "dev"
|
||||
// version is the application version. Release builds may override it via
|
||||
// -ldflags "-X main.version=<version>".
|
||||
var version = "0.3.3"
|
||||
|
||||
const banner = `
|
||||
_ ____ _____ _______ __
|
||||
|
||||
+7
-2
@@ -323,8 +323,8 @@ func (s *CompanyStore) AddScope(companyID int64, lines []string, reason string)
|
||||
return s.AddScopeInputs(companyID, inputs, reason)
|
||||
}
|
||||
|
||||
// AddScopeInputs inserts structured scope rules. Empty kinds retain the legacy
|
||||
// domain/IP/CIDR auto-detection used by AddScope.
|
||||
// AddScopeInputs inserts structured scope rules. Empty kinds use the automatic
|
||||
// CIDR/IP/ICP/domain/keyword classification used by AddScope.
|
||||
func (s *CompanyStore) AddScopeInputs(companyID int64, inputs []ScopeInput, reason string) (added, skipped, invalid int, errors []string) {
|
||||
added, skipped, invalid, validationErrors, err := s.AddScopeInputsChecked(companyID, inputs, reason)
|
||||
if err != nil {
|
||||
@@ -604,6 +604,11 @@ func (s *CompanyStore) UpdateScopeInputsChecked(companyID int64, inputs []ScopeI
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
rules, invalid, errs := parseScopeInputs(inputs)
|
||||
if invalid > 0 {
|
||||
return 0, invalid, errs, &CompanyScopeValidationError{Message: fmt.Sprintf(
|
||||
"企业范围包含 %d 条无效规则,未覆盖原有范围", invalid,
|
||||
)}
|
||||
}
|
||||
if err := validateParsedScopeBounds(rules); err != nil {
|
||||
return 0, invalid, errs, err
|
||||
}
|
||||
|
||||
@@ -255,6 +255,23 @@ func TestUpdateScope(t *testing.T) {
|
||||
if len(scope) != 1 || scope[0].Domain != "new-domain.com" {
|
||||
t.Errorf("UpdateScope: expected new-domain.com only, got %+v", scope)
|
||||
}
|
||||
|
||||
// Invalid replacement input must not turn a partial validation response into
|
||||
// a destructive replacement of the existing rules.
|
||||
added, invalid, validationErrors, err := cs.UpdateScopeInputsChecked(id, []ScopeInput{
|
||||
{Kind: "domain", Value: "co.uk"},
|
||||
}, "invalid replacement")
|
||||
var validationErr *CompanyScopeValidationError
|
||||
if added != 0 || invalid != 1 || len(validationErrors) != 1 || !errors.As(err, &validationErr) {
|
||||
t.Fatalf("invalid replacement: added=%d invalid=%d validation=%v err=%v", added, invalid, validationErrors, err)
|
||||
}
|
||||
scope, err = cs.GetScope(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(scope) != 1 || scope[0].Domain != "new-domain.com" {
|
||||
t.Fatalf("invalid replacement changed existing scope: %+v", scope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateScopeRollsBackOnInsertFailure(t *testing.T) {
|
||||
|
||||
+155
-38
@@ -3,14 +3,16 @@ package db
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/net/idna"
|
||||
"golang.org/x/net/publicsuffix"
|
||||
)
|
||||
|
||||
// ParsedScope is one parsed asset-scope entry. Its kind selects Domain, Net, or Value.
|
||||
// Used internally by ParseScopeLine / ParseScopeLines (passed to CompanyStore).
|
||||
// Used internally by the company scope parsers and CompanyStore.
|
||||
type ParsedScope struct {
|
||||
Kind string // "domain" | "ip" | "cidr" | "icp" | "keyword"
|
||||
Domain string // normalized registrable/root domain (kind=domain)
|
||||
@@ -20,7 +22,7 @@ type ParsedScope struct {
|
||||
}
|
||||
|
||||
// ScopeInput is the structured API form for a company scope rule. Empty Kind
|
||||
// preserves the legacy one-line auto detection for domain/IP/CIDR clients.
|
||||
// uses the same automatic classification as the single-textarea UI.
|
||||
type ScopeInput struct {
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Value string `json:"value"`
|
||||
@@ -41,13 +43,56 @@ func normalizeKeyword(value string) string {
|
||||
return strings.ToLower(strings.Join(strings.Fields(value), " "))
|
||||
}
|
||||
|
||||
func looksLikeIPAddress(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if strings.Contains(value, "://") || strings.IndexFunc(value, unicode.IsSpace) >= 0 {
|
||||
return false
|
||||
}
|
||||
if strings.Count(value, ":") >= 2 {
|
||||
// Require an IPv6-looking prefix. This still catches malformed values such
|
||||
// as 2001:db8::zz without treating ordinary colon-delimited keywords as IPs.
|
||||
parts := strings.Split(value, ":")
|
||||
validSegments := 0
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
if validSegments > 0 || strings.HasPrefix(value, "::") {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(part) > 4 {
|
||||
return false
|
||||
}
|
||||
for _, r := range part {
|
||||
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
validSegments++
|
||||
if validSegments >= 2 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !strings.Contains(value, ".") {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if r != '.' && (r < '0' || r > '9') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ParseScopeInput validates an explicitly typed rule. Legacy callers can omit
|
||||
// Kind and retain the existing domain/IP/CIDR auto-detection behavior.
|
||||
// Kind and use the same automatic classification as the single-textarea UI.
|
||||
func ParseScopeInput(input ScopeInput) (ParsedScope, error) {
|
||||
kind := strings.ToLower(strings.TrimSpace(input.Kind))
|
||||
raw := strings.TrimSpace(input.Value)
|
||||
if kind == "" {
|
||||
return ParseScopeLine(raw)
|
||||
return ParseAutoScopeLine(raw)
|
||||
}
|
||||
switch kind {
|
||||
case "domain", "ip", "cidr":
|
||||
@@ -76,6 +121,93 @@ func ParseScopeInput(input ScopeInput) (ParsedScope, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// ParseAutoScopeLine classifies one untyped textarea line. Network-looking and
|
||||
// domain-looking values remain strict so malformed ranges do not silently become
|
||||
// Agent keywords; all other non-empty text is a keyword.
|
||||
func ParseAutoScopeLine(line string) (ParsedScope, error) {
|
||||
raw := strings.TrimSpace(line)
|
||||
if raw == "" {
|
||||
return ParsedScope{}, fmt.Errorf("空行")
|
||||
}
|
||||
|
||||
if _, _, err := net.ParseCIDR(raw); err == nil {
|
||||
return ParseScopeLine(raw)
|
||||
}
|
||||
if ip := net.ParseIP(raw); ip != nil {
|
||||
return ParseScopeLine(raw)
|
||||
}
|
||||
if slash := strings.LastIndexByte(raw, '/'); slash > 0 {
|
||||
address := strings.TrimSpace(raw[:slash])
|
||||
if net.ParseIP(address) != nil || looksLikeIPAddress(address) {
|
||||
return ParsedScope{Raw: raw}, fmt.Errorf("无效 CIDR: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
if looksLikeIPAddress(raw) {
|
||||
return ParsedScope{Raw: raw}, fmt.Errorf("无效 IP: %s", raw)
|
||||
}
|
||||
|
||||
looksLikeDomain := strings.Contains(raw, "://") ||
|
||||
(strings.Contains(raw, ".") && strings.IndexFunc(raw, unicode.IsSpace) < 0)
|
||||
if looksLikeDomain {
|
||||
return ParseScopeLine(raw)
|
||||
}
|
||||
lower := strings.ToLower(raw)
|
||||
if strings.Contains(lower, "icp") || strings.Contains(raw, "备案") {
|
||||
return ParseScopeInput(ScopeInput{Kind: "icp", Value: raw})
|
||||
}
|
||||
return ParseScopeInput(ScopeInput{Kind: "keyword", Value: raw})
|
||||
}
|
||||
|
||||
func scopeHostname(raw string) (string, error) {
|
||||
candidate := strings.TrimSpace(raw)
|
||||
if candidate == "" {
|
||||
return "", fmt.Errorf("主机名为空")
|
||||
}
|
||||
if strings.HasPrefix(candidate, "//") {
|
||||
candidate = "http:" + candidate
|
||||
} else if !strings.Contains(candidate, "://") {
|
||||
candidate = "http://" + candidate
|
||||
}
|
||||
parsed, err := url.Parse(candidate)
|
||||
if err != nil || parsed.Host == "" {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("缺少主机名")
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
host := strings.TrimSuffix(strings.TrimSpace(parsed.Hostname()), ".")
|
||||
if host == "" {
|
||||
return "", fmt.Errorf("主机名为空")
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return ip.String(), nil
|
||||
}
|
||||
host, err = idna.Lookup.ToASCII(host)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
host = strings.ToLower(host)
|
||||
if len(host) > 253 {
|
||||
return "", fmt.Errorf("域名超过 253 个字符")
|
||||
}
|
||||
labels := strings.Split(host, ".")
|
||||
if len(labels) < 2 {
|
||||
return "", fmt.Errorf("域名至少需要两个标签")
|
||||
}
|
||||
for _, label := range labels {
|
||||
if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
|
||||
return "", fmt.Errorf("域名标签无效")
|
||||
}
|
||||
for _, r := range label {
|
||||
if !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-') {
|
||||
return "", fmt.Errorf("域名包含无效字符")
|
||||
}
|
||||
}
|
||||
}
|
||||
return host, nil
|
||||
}
|
||||
|
||||
// ParseScopeLine classifies and validates one scope line (root domain / IP /
|
||||
// CIDR). Guardrails reject bare TLDs and over-broad networks so a rule can never
|
||||
// swallow the internet. IP ranges must be expressed as CIDR.
|
||||
@@ -85,12 +217,8 @@ func ParseScopeLine(line string) (ParsedScope, error) {
|
||||
if raw == "" {
|
||||
return r, fmt.Errorf("空行")
|
||||
}
|
||||
s := raw
|
||||
if i := strings.Index(s, "://"); i >= 0 { // strip scheme if a URL was pasted
|
||||
s = s[i+3:]
|
||||
}
|
||||
// CIDR first — it contains '/', which the path-strip below would remove.
|
||||
if _, ipnet, err := net.ParseCIDR(s); err == nil {
|
||||
// CIDR first because URL parsing treats its slash as a path separator.
|
||||
if _, ipnet, err := net.ParseCIDR(raw); err == nil {
|
||||
ones, bits := ipnet.Mask.Size()
|
||||
if bits == 32 && ones < 16 {
|
||||
return r, fmt.Errorf("网段过宽(IPv4 需 >= /16): %s", raw)
|
||||
@@ -101,12 +229,8 @@ func ParseScopeLine(line string) (ParsedScope, error) {
|
||||
r.Kind, r.Net = "cidr", ipnet.String()
|
||||
return r, nil
|
||||
}
|
||||
// Strip any path and :port remnants for host-like inputs.
|
||||
if i := strings.IndexByte(s, '/'); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
// Single IP.
|
||||
if ip := net.ParseIP(strings.TrimSpace(s)); ip != nil {
|
||||
if ip := net.ParseIP(raw); ip != nil {
|
||||
r.Kind = "ip"
|
||||
if ip.To4() != nil {
|
||||
r.Net = ip.String() + "/32"
|
||||
@@ -115,37 +239,30 @@ func ParseScopeLine(line string) (ParsedScope, error) {
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
if host, _, ok := strings.Cut(s, ":"); ok { // host:port → host
|
||||
s = host
|
||||
host, err := scopeHostname(raw)
|
||||
if err != nil {
|
||||
return r, fmt.Errorf("无法识别为有效域名/IP/CIDR: %s", raw)
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
r.Kind = "ip"
|
||||
if ip.To4() != nil {
|
||||
r.Net = ip.String() + "/32"
|
||||
} else {
|
||||
r.Net = ip.String() + "/128"
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
if looksLikeIPAddress(host) {
|
||||
return r, fmt.Errorf("无效 IP: %s", raw)
|
||||
}
|
||||
if strings.Contains(raw, "-") && strings.Count(raw, ".") >= 6 {
|
||||
return r, fmt.Errorf("IP 段请用 CIDR 表示(如 1.2.3.0/24): %s", raw)
|
||||
}
|
||||
// Domain (registrable). Reject bare TLDs / public suffixes.
|
||||
d := DomainKey(s)
|
||||
if d == "" || !strings.Contains(d, ".") {
|
||||
return r, fmt.Errorf("无法识别为根域名/IP/CIDR: %s", raw)
|
||||
}
|
||||
d := DomainKey(host)
|
||||
if suf, icann := publicsuffix.PublicSuffix(d); icann && suf == d {
|
||||
return r, fmt.Errorf("不能用裸 TLD 作为范围: %s", raw)
|
||||
}
|
||||
r.Kind, r.Domain = "domain", d
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// ParseScopeLines parses a whole text block (one entry per line), returning the
|
||||
// valid rules and the invalid lines (with reasons) so the caller can report both.
|
||||
func ParseScopeLines(text string) (rules []ParsedScope, invalid []string) {
|
||||
for _, ln := range strings.Split(text, "\n") {
|
||||
if strings.TrimSpace(ln) == "" {
|
||||
continue
|
||||
}
|
||||
r, err := ParseScopeLine(ln)
|
||||
if err != nil {
|
||||
invalid = append(invalid, err.Error())
|
||||
continue
|
||||
}
|
||||
rules = append(rules, r)
|
||||
}
|
||||
return rules, invalid
|
||||
}
|
||||
@@ -40,6 +40,65 @@ func TestParseScopeLine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAutoScopeLine(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
kind string
|
||||
normalized string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "domain", input: "example.com", kind: "domain", normalized: "example.com"},
|
||||
{name: "url", input: "https://sub.example.com/path", kind: "domain", normalized: "sub.example.com"},
|
||||
{name: "url query", input: "https://example.com/path?source=x", kind: "domain", normalized: "example.com"},
|
||||
{name: "url credentials", input: "https://user:[email protected]/path", kind: "domain", normalized: "example.com"},
|
||||
{name: "url ip", input: "http://203.0.113.10/path", kind: "ip", normalized: "203.0.113.10/32"},
|
||||
{name: "ipv4", input: "203.0.113.10", kind: "ip", normalized: "203.0.113.10/32"},
|
||||
{name: "ipv6", input: "2001:db8::10", kind: "ip", normalized: "2001:db8::10/128"},
|
||||
{name: "cidr", input: "198.51.100.0/24", kind: "cidr", normalized: "198.51.100.0/24"},
|
||||
{name: "icp latin", input: "京 ICP备 123号", kind: "icp", normalized: "京icp备123号"},
|
||||
{name: "icp chinese", input: "沪网备案 9988", kind: "icp", normalized: "沪网备案9988"},
|
||||
{name: "icp domain", input: "icp.example.com", kind: "domain", normalized: "icp.example.com"},
|
||||
{name: "icp url query", input: "https://example.com/path?icp=1", kind: "domain", normalized: "example.com"},
|
||||
{name: "keyword", input: " ACME Security ", kind: "keyword", normalized: "acme security"},
|
||||
{name: "colon keyword", input: "ACME: Cloud: Security", kind: "keyword", normalized: "acme: cloud: security"},
|
||||
{name: "empty", input: " ", wantErr: true},
|
||||
{name: "invalid cidr", input: "10.0.0.0/not-a-prefix", wantErr: true},
|
||||
{name: "invalid ipv4", input: "999.0.0.1", wantErr: true},
|
||||
{name: "invalid ipv6", input: "2001:db8::zz", wantErr: true},
|
||||
{name: "invalid ipv6 cidr", input: "2001:db8::zz/64", wantErr: true},
|
||||
{name: "overbroad cidr", input: "10.0.0.0/8", wantErr: true},
|
||||
{name: "bare suffix", input: "co.uk", wantErr: true},
|
||||
{name: "empty domain label", input: "foo..example.com", wantErr: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rule, err := ParseAutoScopeLine(tc.input)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("ParseAutoScopeLine(%q) = %+v, want error", tc.input, rule)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAutoScopeLine(%q): %v", tc.input, err)
|
||||
}
|
||||
if rule.Kind != tc.kind {
|
||||
t.Fatalf("kind=%q want %q", rule.Kind, tc.kind)
|
||||
}
|
||||
got := rule.Value
|
||||
if rule.Kind == "domain" {
|
||||
got = rule.Domain
|
||||
} else if rule.Kind == "ip" || rule.Kind == "cidr" {
|
||||
got = rule.Net
|
||||
}
|
||||
if got != tc.normalized {
|
||||
t.Fatalf("normalized=%q want %q", got, tc.normalized)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitCompanyAttributionSurvivesScopeRebuild(t *testing.T) {
|
||||
d, as, cs := testSetup(t)
|
||||
defer d.Close()
|
||||
@@ -277,13 +336,6 @@ func TestCompanyScopeAttribution(t *testing.T) {
|
||||
t.Fatalf("pre-scope asset should be unattributed, got %v", *preCompanyID)
|
||||
}
|
||||
|
||||
rules, invalid := ParseScopeLines(root + "\n198.51.100.0/24\nco.uk")
|
||||
if len(rules) != 2 {
|
||||
t.Fatalf("want 2 valid rules, got %d (%+v)", len(rules), rules)
|
||||
}
|
||||
if len(invalid) != 1 {
|
||||
t.Fatalf("want 1 invalid line, got %v", invalid)
|
||||
}
|
||||
cs.AddScope(cid, []string{root, "198.51.100.0/24"}, "unit test")
|
||||
|
||||
mustCid := func(id int64, want int64, label string) {
|
||||
|
||||
@@ -667,7 +667,6 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("PUT /api/tasks/{id}/llm", s.updateTaskLLMProfiles)
|
||||
mux.HandleFunc("GET /api/tasks/{id}/llm/resolution", s.taskLLMResolutionHandler)
|
||||
mux.HandleFunc("POST /api/tasks/{id}/intents/{iid}/control", s.controlIntent)
|
||||
mux.HandleFunc("POST /api/tasks/{id}/intents/control/batch", s.controlIntentsBatch)
|
||||
mux.HandleFunc("POST /api/tasks/{id}/intents/{iid}/rerun", s.rerunIntent) // 重跑单条 blocked/exhausted/stopped 意图
|
||||
mux.HandleFunc("POST /api/tasks/{id}/intents/rerun-blocked", s.rerunBlocked) // 批量重跑本任务全部 blocked 意图
|
||||
mux.HandleFunc("POST /api/active", s.setActive)
|
||||
|
||||
@@ -688,43 +688,6 @@ func TestBatchControlLimitCountsDeduplicatedIDs(t *testing.T) {
|
||||
t.Fatalf("unique task ids status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
task, err := m.CreateTask("batch intent limit", "deduplicate intent ids", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _, _ = m.DeleteTask(task.ID, DeleteTaskOptions{}) }()
|
||||
repeatedIntents := make([]string, maxBatchControlIDs+1)
|
||||
for i := range repeatedIntents {
|
||||
repeatedIntents[i] = "999999"
|
||||
}
|
||||
rec = request(s.controlIntentsBatch, "/api/tasks/1/intents/control/batch", map[string]string{"id": task.ID}, map[string]any{
|
||||
"intent_ids": repeatedIntents,
|
||||
"action": "pause",
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("deduplicated intent ids status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var intentPayload struct {
|
||||
Items []batchControlItem `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &intentPayload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(intentPayload.Items) != 1 || intentPayload.Items[0].ID != "999999" {
|
||||
t.Fatalf("deduplicated intent response=%+v", intentPayload.Items)
|
||||
}
|
||||
|
||||
uniqueIntents := make([]string, maxBatchControlIDs+1)
|
||||
for i := range uniqueIntents {
|
||||
uniqueIntents[i] = strconv.Itoa(i + 1)
|
||||
}
|
||||
rec = request(s.controlIntentsBatch, "/api/tasks/1/intents/control/batch", map[string]string{"id": task.ID}, map[string]any{
|
||||
"intent_ids": uniqueIntents,
|
||||
"action": "resume",
|
||||
})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("unique intent ids status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPauseTaskToolPersistsAndDequeuesTask(t *testing.T) {
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
@@ -32,7 +31,6 @@ type batchControlItem struct {
|
||||
ID string `json:"id"`
|
||||
OK bool `json:"ok"`
|
||||
Status string `json:"status,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
Queued bool `json:"queued,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
@@ -241,74 +239,3 @@ func (s *Server) controlTasksBatch(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (s *Server) controlIntentsBatch(w http.ResponseWriter, r *http.Request) {
|
||||
t, ok := s.m.Task(r.PathValue("id"))
|
||||
if !ok {
|
||||
writeErr(w, 404, "task not found")
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 256<<10)
|
||||
var req struct {
|
||||
IntentIDs []string `json:"intent_ids"`
|
||||
Action string `json:"action"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, 400, "bad json: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Action != "pause" && req.Action != "resume" {
|
||||
writeErr(w, 400, "action must be pause|resume")
|
||||
return
|
||||
}
|
||||
type parsedIntentID struct {
|
||||
raw string
|
||||
id int64
|
||||
valid bool
|
||||
}
|
||||
seen := map[int64]bool{}
|
||||
seenInvalid := map[string]bool{}
|
||||
intentIDs := make([]parsedIntentID, 0, len(req.IntentIDs))
|
||||
for _, raw := range req.IntentIDs {
|
||||
iid, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || iid <= 0 {
|
||||
if seenInvalid[raw] {
|
||||
continue
|
||||
}
|
||||
seenInvalid[raw] = true
|
||||
intentIDs = append(intentIDs, parsedIntentID{raw: raw})
|
||||
continue
|
||||
}
|
||||
if seen[iid] {
|
||||
continue
|
||||
}
|
||||
seen[iid] = true
|
||||
intentIDs = append(intentIDs, parsedIntentID{raw: raw, id: iid, valid: true})
|
||||
}
|
||||
if len(intentIDs) == 0 || len(intentIDs) > maxBatchControlIDs {
|
||||
writeErr(w, 400, fmt.Sprintf("intent_ids 数量必须为 1-%d", maxBatchControlIDs))
|
||||
return
|
||||
}
|
||||
if !s.engine.beginTaskOperation(t.ID) {
|
||||
writeErr(w, 409, "任务正在删除,无法控制意图")
|
||||
return
|
||||
}
|
||||
defer s.engine.decInflight(t.ID)
|
||||
items := make([]batchControlItem, 0, len(intentIDs))
|
||||
for _, parsed := range intentIDs {
|
||||
item := batchControlItem{ID: parsed.raw}
|
||||
if !parsed.valid {
|
||||
item.Error = "bad intent id"
|
||||
items = append(items, item)
|
||||
continue
|
||||
}
|
||||
result, err := s.applyIntentControl(r.Context(), t, parsed.id, req.Action)
|
||||
if err != nil {
|
||||
item.Error = err.Error()
|
||||
} else {
|
||||
item.OK, item.State = true, result.State
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"items": items})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWorkerControlRoutes(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
td := t.TempDir()
|
||||
s := New(context.Background(), m, td, td, td)
|
||||
h := s.Handler()
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
t.Fatalf("sign jwt: %v", err)
|
||||
}
|
||||
|
||||
request := func(path string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"action":"pause"}`))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// The single-worker route is still registered and reaches its JSON handler.
|
||||
single := request("/api/tasks/missing/intents/1/control")
|
||||
if single.Code != http.StatusNotFound || !strings.Contains(single.Body.String(), `"error":"task not found"`) {
|
||||
t.Fatalf("single control route unavailable: status=%d body=%s", single.Code, single.Body.String())
|
||||
}
|
||||
|
||||
// The former batch route must fall through the mux instead of reaching a task handler.
|
||||
batch := request("/api/tasks/missing/intents/control/batch")
|
||||
if batch.Code != http.StatusNotFound || !strings.Contains(batch.Body.String(), "404 page not found") {
|
||||
t.Fatalf("batch control route still registered: status=%d body=%s", batch.Code, batch.Body.String())
|
||||
}
|
||||
}
|
||||
+21
-1
@@ -219,7 +219,7 @@ func (t *Traffic) record(f *mproxy.Flow) {
|
||||
respBody := t.bodyOrBlob(f.Response.Body)
|
||||
ct := f.Response.Header.Get("Content-Type")
|
||||
|
||||
reqTxt := fmt.Sprintf("%s %s %s\n%s\n%s", method, f.Request.URL.RequestURI(), f.Request.Proto, headerLines(f.Request.Header), reqBody)
|
||||
reqTxt := fmt.Sprintf("%s %s %s\n%s\n%s", method, f.Request.URL.RequestURI(), f.Request.Proto, requestHeaderLines(f.Request), reqBody)
|
||||
respTxt := fmt.Sprintf("HTTP %d\n%s\n%s", f.Response.StatusCode, headerLines(f.Response.Header), respBody)
|
||||
_ = os.WriteFile(filepath.Join(exDir, "request.http"), []byte(reqTxt), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(exDir, "response.http"), []byte(respTxt), 0o644)
|
||||
@@ -267,6 +267,26 @@ func headerLines(h map[string][]string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// requestHeaderLines restores the HTTP Host header, which net/http stores on
|
||||
// Request.Host rather than in Header. Keeping it in request.http makes the raw
|
||||
// capture complete and directly replayable.
|
||||
func requestHeaderLines(req *mproxy.Request) string {
|
||||
headers := req.Header.Clone()
|
||||
headers.Del("Host")
|
||||
host := req.URL.Host
|
||||
if raw := req.Raw(); raw != nil && strings.TrimSpace(raw.Host) != "" {
|
||||
host = raw.Host
|
||||
}
|
||||
var b strings.Builder
|
||||
if host = strings.TrimSpace(host); host != "" {
|
||||
b.WriteString("Host: ")
|
||||
b.WriteString(host)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
b.WriteString(headerLines(headers))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func sanitize(s string) string {
|
||||
r := strings.NewReplacer("/", "_", "\\", "_", ":", "_", "?", "_", "*", "_", "\"", "_", "<", "_", ">", "_", "|", "_")
|
||||
out := r.Replace(s)
|
||||
|
||||
@@ -2,12 +2,30 @@ package traffic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
|
||||
)
|
||||
|
||||
func TestRequestHeaderLinesIncludesHost(t *testing.T) {
|
||||
req := &mproxy.Request{
|
||||
URL: &url.URL{Host: "target.example:8443"},
|
||||
Header: http.Header{"Accept": []string{"application/json"}},
|
||||
}
|
||||
got := requestHeaderLines(req)
|
||||
if !strings.Contains(got, "Host: target.example:8443\n") {
|
||||
t.Fatalf("request headers missing Host: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "Accept: application/json\n") {
|
||||
t.Fatalf("request headers missing regular header: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteHost verifies the delete contract: rows for hosts containing the
|
||||
// substring are removed together with their file trees, non-matching hosts are
|
||||
// untouched, and the count is right.
|
||||
|
||||
@@ -35,7 +35,7 @@ export function MainContent({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
api
|
||||
.health()
|
||||
.then((h) => setVersion(h.version ?? ""))
|
||||
.then((h) => setVersion((h.version ?? "").replace(/^v(?=\d)/, "")))
|
||||
.catch(() => setVersion(""));
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
ArrowUpIcon,
|
||||
Bot,
|
||||
ChevronDownIcon,
|
||||
Loader2Icon,
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
PinIcon,
|
||||
PinOffIcon,
|
||||
PlusIcon,
|
||||
SendIcon,
|
||||
Square,
|
||||
Trash2Icon,
|
||||
XIcon,
|
||||
@@ -202,8 +202,14 @@ function Composer({
|
||||
<Square className="size-3.5 fill-current" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="icon" onClick={onSend} disabled={disabled || (!value.trim() && atts.length === 0)}>
|
||||
<SendIcon className="size-4" />
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={onSend}
|
||||
disabled={disabled || (!value.trim() && atts.length === 0)}
|
||||
title="发送消息"
|
||||
aria-label="发送消息"
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
BugIcon,
|
||||
ClockIcon,
|
||||
NetworkIcon,
|
||||
PlusIcon,
|
||||
ShieldCheckIcon,
|
||||
TargetIcon,
|
||||
ZapIcon,
|
||||
@@ -177,7 +176,7 @@ export default function DashboardPage() {
|
||||
/* transient errors — next poll retries */
|
||||
}
|
||||
};
|
||||
load();
|
||||
void load();
|
||||
const t = setInterval(load, 5000);
|
||||
return () => {
|
||||
alive = false;
|
||||
@@ -213,7 +212,7 @@ export default function DashboardPage() {
|
||||
/* transient errors */
|
||||
}
|
||||
};
|
||||
load();
|
||||
void load();
|
||||
const t = setInterval(load, 15000);
|
||||
return () => {
|
||||
alive = false;
|
||||
@@ -461,17 +460,11 @@ export default function DashboardPage() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 pb-6">
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold tracking-tight">总览</h1>
|
||||
<p className="text-xs text-muted-foreground">系统全局状态 · 实时刷新</p>
|
||||
</div>
|
||||
<Link href="/function/tasks">
|
||||
<button className="flex items-center gap-1.5 rounded-lg bg-foreground px-3 py-1.5 text-xs font-medium text-background transition-opacity hover:opacity-90">
|
||||
<PlusIcon className="size-3.5" />
|
||||
新建任务
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* ── Row 1: 5 stat cards ── */}
|
||||
@@ -575,6 +568,7 @@ export default function DashboardPage() {
|
||||
] as const
|
||||
).map(({ v, label }) => (
|
||||
<button
|
||||
type="button"
|
||||
key={v}
|
||||
onClick={() => setTokenVersion(v)}
|
||||
title={
|
||||
@@ -600,6 +594,7 @@ export default function DashboardPage() {
|
||||
{/* Profile tabs */}
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTokenTab("all")}
|
||||
className={cn(
|
||||
"rounded-md px-2.5 py-1 text-[10px] font-medium transition-colors",
|
||||
@@ -616,6 +611,7 @@ export default function DashboardPage() {
|
||||
const resolvedKey = tokenByProfile.has(key) ? key : p.is_default ? defaultProfileId : key;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={p.id}
|
||||
onClick={() => setTokenTab(resolvedKey)}
|
||||
className={cn(
|
||||
@@ -745,6 +741,7 @@ export default function DashboardPage() {
|
||||
] as const
|
||||
).map(({ days, label }) => (
|
||||
<button
|
||||
type="button"
|
||||
key={days}
|
||||
onClick={() => setTokenDays(days)}
|
||||
className={cn(
|
||||
@@ -993,7 +990,7 @@ export default function DashboardPage() {
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 tabular-nums">
|
||||
{(t.in_flight ?? 0 > 0) ? (
|
||||
{(t.in_flight ?? 0) > 0 ? (
|
||||
<span className="font-semibold">{t.in_flight}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
LinkIcon,
|
||||
type LucideIcon,
|
||||
NetworkIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
SearchIcon,
|
||||
SmartphoneIcon,
|
||||
@@ -46,7 +45,6 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Sheet,
|
||||
@@ -59,7 +57,9 @@ import {
|
||||
} from "@/components/ui/sheet";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/lib/api";
|
||||
import { MAX_COMPANY_SCOPE_RULES, type ParsedCompanyScopeText, parseCompanyScopeText } from "@/lib/company-scope";
|
||||
import type { Asset, Company, CompanyScopeKind, CompanyScopeRule } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -1254,11 +1254,13 @@ function AssetCard({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAGE_SIZES.map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>
|
||||
{n} / 页
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectGroup>
|
||||
{PAGE_SIZES.map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>
|
||||
{n} / 页
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="tabular-nums">
|
||||
@@ -1319,171 +1321,94 @@ function CompanyAvatar({ name, logo }: { name: string; logo?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
const COMPANY_SCOPE_KINDS: { value: CompanyScopeKind; label: string; placeholder: string }[] = [
|
||||
{ value: "domain", label: "域名", placeholder: "example.com" },
|
||||
{ value: "ip", label: "IP", placeholder: "203.0.113.10" },
|
||||
{ value: "cidr", label: "CIDR", placeholder: "198.51.100.0/24" },
|
||||
{ value: "icp", label: "ICP", placeholder: "京ICP备12345678号-1" },
|
||||
{ value: "keyword", label: "企业关键词", placeholder: "Acme / 产品品牌名" },
|
||||
];
|
||||
const COMPANY_SCOPE_KIND_LABELS: Record<CompanyScopeKind, string> = {
|
||||
domain: "域名",
|
||||
ip: "IP",
|
||||
cidr: "CIDR",
|
||||
icp: "ICP",
|
||||
keyword: "关键词",
|
||||
};
|
||||
|
||||
const MAX_COMPANY_SCOPE_RULES = 256;
|
||||
const MAX_COMPANY_SCOPE_VALUE_LENGTH = 1024;
|
||||
|
||||
type EditableScopeRule = CompanyScopeRule & { key: string };
|
||||
let nextScopeRuleKey = 0;
|
||||
|
||||
function editableScopeRule(kind: CompanyScopeKind = "domain", value = ""): EditableScopeRule {
|
||||
nextScopeRuleKey += 1;
|
||||
return { kind, value, key: `company-scope-${nextScopeRuleKey}` };
|
||||
}
|
||||
|
||||
function isIPv4(value: string): boolean {
|
||||
const parts = value.split(".");
|
||||
return (
|
||||
parts.length === 4 &&
|
||||
parts.every((part) => (part === "0" || /^[1-9]\d{0,2}$/.test(part)) && Number(part) >= 0 && Number(part) <= 255)
|
||||
);
|
||||
}
|
||||
|
||||
function isIPv6(value: string): boolean {
|
||||
if (!value.includes(":") || /[^0-9a-f:.]/i.test(value) || value.includes(":::")) return false;
|
||||
const halves = value.split("::");
|
||||
if (halves.length > 2) return false;
|
||||
const countSegments = (half: string): number | null => {
|
||||
if (!half) return 0;
|
||||
const segments = half.split(":");
|
||||
let count = 0;
|
||||
for (const [index, segment] of segments.entries()) {
|
||||
if (segment.includes(".")) {
|
||||
if (index !== segments.length - 1 || !isIPv4(segment)) return null;
|
||||
count += 2;
|
||||
} else {
|
||||
if (!/^[0-9a-f]{1,4}$/i.test(segment)) return null;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
};
|
||||
const left = countSegments(halves[0]);
|
||||
const right = countSegments(halves[1] ?? "");
|
||||
if (left === null || right === null) return false;
|
||||
return halves.length === 2 ? left + right < 8 : left === 8;
|
||||
}
|
||||
|
||||
function ipVersion(value: string): 4 | 6 | null {
|
||||
if (isIPv4(value)) return 4;
|
||||
if (isIPv6(value)) return 6;
|
||||
return null;
|
||||
}
|
||||
|
||||
function scopeRuleError(rule: CompanyScopeRule): string {
|
||||
const value = rule.value.trim();
|
||||
if (!value) return "请填写范围值";
|
||||
if (rule.kind === "domain" && (!value.includes(".") || /\s/.test(value))) return "请输入有效域名";
|
||||
if (rule.kind === "ip" && ipVersion(value) === null) return "请输入有效 IP";
|
||||
if (rule.kind === "cidr") {
|
||||
const separator = value.lastIndexOf("/");
|
||||
if (separator <= 0) return "请输入 CIDR 网段";
|
||||
const address = value.slice(0, separator);
|
||||
const prefixText = value.slice(separator + 1);
|
||||
const version = ipVersion(address);
|
||||
if (version === null || !/^\d+$/.test(prefixText)) return "请输入 CIDR 网段";
|
||||
const prefix = Number(prefixText);
|
||||
if (version === 4 && (prefix < 16 || prefix > 32)) return "IPv4 网段前缀需为 /16 至 /32";
|
||||
if (version === 6 && (prefix < 32 || prefix > 128)) return "IPv6 网段前缀需为 /32 至 /128";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function ScopeRulesEditor({
|
||||
function ScopeTextEditor({
|
||||
id,
|
||||
value,
|
||||
onValueChange,
|
||||
parsed,
|
||||
}: {
|
||||
value: EditableScopeRule[];
|
||||
onValueChange: (value: EditableScopeRule[]) => void;
|
||||
id: string;
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
parsed: ParsedCompanyScopeText;
|
||||
}) {
|
||||
const update = (index: number, patch: Partial<EditableScopeRule>) => {
|
||||
onValueChange(value.map((rule, i) => (i === index ? { ...rule, ...patch } : rule)));
|
||||
};
|
||||
const counts = React.useMemo(() => {
|
||||
const result = new Map<CompanyScopeKind, number>();
|
||||
for (const rule of parsed.rules) result.set(rule.kind, (result.get(rule.kind) ?? 0) + 1);
|
||||
return result;
|
||||
}, [parsed.rules]);
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{value.map((rule, index) => {
|
||||
const meta = COMPANY_SCOPE_KINDS.find((item) => item.value === rule.kind) ?? COMPANY_SCOPE_KINDS[0];
|
||||
// Empty rows are omitted on submit, so the initial optional row should
|
||||
// not appear invalid before the user enters a value.
|
||||
const error = rule.value.trim() ? scopeRuleError(rule) : "";
|
||||
return (
|
||||
<Field key={rule.key} data-invalid={!!error}>
|
||||
<div className="flex items-start gap-2">
|
||||
<Select value={rule.kind} onValueChange={(kind) => update(index, { kind: kind as CompanyScopeKind })}>
|
||||
<SelectTrigger className="w-32 shrink-0" aria-label={`范围 ${index + 1} 类型`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{COMPANY_SCOPE_KINDS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={rule.value}
|
||||
placeholder={meta.placeholder}
|
||||
aria-label={`范围 ${index + 1} 内容`}
|
||||
aria-invalid={!!error}
|
||||
maxLength={MAX_COMPANY_SCOPE_VALUE_LENGTH}
|
||||
onChange={(event) => update(index, { value: event.target.value })}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`删除范围 ${index + 1}`}
|
||||
disabled={value.length === 1}
|
||||
onClick={() => onValueChange(value.filter((_, i) => i !== index))}
|
||||
>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
</div>
|
||||
{rule.value && error ? <FieldError>{error}</FieldError> : null}
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="self-start"
|
||||
disabled={value.length >= MAX_COMPANY_SCOPE_RULES}
|
||||
title={value.length >= MAX_COMPANY_SCOPE_RULES ? `每个企业最多 ${MAX_COMPANY_SCOPE_RULES} 条范围` : undefined}
|
||||
onClick={() => onValueChange([...value, editableScopeRule()])}
|
||||
>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
添加范围
|
||||
</Button>
|
||||
</div>
|
||||
<Field data-invalid={parsed.errors.length > 0}>
|
||||
<FieldLabel htmlFor={id}>资产范围</FieldLabel>
|
||||
<FieldDescription>每行一条,自动识别域名、IP、CIDR、ICP 备案和企业关键词。</FieldDescription>
|
||||
<Textarea
|
||||
id={id}
|
||||
rows={8}
|
||||
value={value}
|
||||
aria-invalid={parsed.errors.length > 0}
|
||||
placeholder={"example.com\n203.0.113.10\n198.51.100.0/24\n京ICP备12345678号-1\n企业名称关键词"}
|
||||
className="min-h-36 resize-y font-mono text-sm"
|
||||
onChange={(event) => onValueChange(event.target.value)}
|
||||
/>
|
||||
{parsed.rules.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>已识别 {parsed.rules.length} 条</span>
|
||||
{Object.entries(COMPANY_SCOPE_KIND_LABELS).map(([kind, label]) => {
|
||||
const count = counts.get(kind as CompanyScopeKind) ?? 0;
|
||||
return count > 0 ? (
|
||||
<Badge key={kind} variant="secondary" className="font-mono tabular-nums">
|
||||
{label} {count}
|
||||
</Badge>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{parsed.errors.length > 0 && (
|
||||
<FieldError>
|
||||
{parsed.errors.slice(0, 5).map((item) => (
|
||||
<span key={`${item.line}-${item.error}`} className="block">
|
||||
第 {item.line} 行:{item.error}
|
||||
</span>
|
||||
))}
|
||||
{parsed.errors.length > 5 && <span className="block">另有 {parsed.errors.length - 5} 行错误</span>}
|
||||
</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
function savedScopeRules(company: Company): EditableScopeRule[] {
|
||||
const rules = (company.scope ?? []).map((scope) => editableScopeRule(scope.kind, scope.raw));
|
||||
return rules.length > 0 ? rules : [editableScopeRule()];
|
||||
function savedScopeRules(company: Company): CompanyScopeRule[] {
|
||||
return (company.scope ?? [])
|
||||
.map((scope) => ({ kind: scope.kind, value: scope.raw.trim() }))
|
||||
.filter((scope) => scope.value);
|
||||
}
|
||||
|
||||
function savedScopeText(company: Company): string {
|
||||
return savedScopeRules(company)
|
||||
.map((scope) => scope.value)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// 新增企业使用与任务、LLM 编辑一致的右侧抽屉。
|
||||
function CompanyDialog({ onSaved }: { onSaved: () => void }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [name, setName] = React.useState("");
|
||||
const [scope, setScope] = React.useState<EditableScopeRule[]>(() => [editableScopeRule()]);
|
||||
const [scopeText, setScopeText] = React.useState("");
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const parsedScope = React.useMemo(() => parseCompanyScopeText(scopeText), [scopeText]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setName("");
|
||||
setScope([editableScopeRule()]);
|
||||
setScopeText("");
|
||||
}, [open]);
|
||||
|
||||
const submit = async () => {
|
||||
@@ -1491,16 +1416,13 @@ function CompanyDialog({ onSaved }: { onSaved: () => void }) {
|
||||
toast.error("请填写企业名称");
|
||||
return;
|
||||
}
|
||||
const rules: CompanyScopeRule[] = scope
|
||||
.filter((rule) => rule.value.trim())
|
||||
.map((rule) => ({ kind: rule.kind, value: rule.value.trim() }));
|
||||
if (rules.some(scopeRuleError)) {
|
||||
if (parsedScope.errors.length > 0) {
|
||||
toast.error("请修正无效的资产范围");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api.createCompany(name.trim(), rules);
|
||||
const res = await api.createCompany(name.trim(), parsedScope.rules);
|
||||
const added = res.scope_added ?? 0;
|
||||
const invalid = res.scope_invalid ?? 0;
|
||||
if (invalid > 0) toast.warning(`已创建企业,添加 ${added} 条范围;${invalid} 行无效`);
|
||||
@@ -1539,18 +1461,14 @@ function CompanyDialog({ onSaved }: { onSaved: () => void }) {
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>资产范围</FieldLabel>
|
||||
<FieldDescription>支持域名、IP、CIDR、ICP 备案和企业名称关键词。</FieldDescription>
|
||||
<ScopeRulesEditor value={scope} onValueChange={setScope} />
|
||||
</Field>
|
||||
<ScopeTextEditor id="cn-scope" value={scopeText} onValueChange={setScopeText} parsed={parsedScope} />
|
||||
</FieldGroup>
|
||||
</div>
|
||||
<SheetFooter className="flex-row justify-end gap-2 border-t p-4">
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={busy}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={busy || !name.trim()}>
|
||||
<Button onClick={submit} disabled={busy || !name.trim() || parsedScope.errors.length > 0}>
|
||||
{busy ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
@@ -1562,27 +1480,29 @@ function CompanyDialog({ onSaved }: { onSaved: () => void }) {
|
||||
// 编辑(覆盖)资产范围弹窗
|
||||
function EditScopeDialog({ company, onSaved }: { company: Company; onSaved: () => void }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [scope, setScope] = React.useState<EditableScopeRule[]>(() => [editableScopeRule()]);
|
||||
const [scopeText, setScopeText] = React.useState("");
|
||||
const [reason, setReason] = React.useState("");
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const preservedScopeRules = React.useMemo(() => savedScopeRules(company), [company]);
|
||||
const parsedScope = React.useMemo(
|
||||
() => parseCompanyScopeText(scopeText, { preservedRules: preservedScopeRules }),
|
||||
[preservedScopeRules, scopeText],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setScope(savedScopeRules(company));
|
||||
setScopeText(savedScopeText(company));
|
||||
setReason("");
|
||||
}, [open, company]);
|
||||
|
||||
const submit = async () => {
|
||||
const rules: CompanyScopeRule[] = scope
|
||||
.filter((rule) => rule.value.trim())
|
||||
.map((rule) => ({ kind: rule.kind, value: rule.value.trim() }));
|
||||
if (rules.some(scopeRuleError)) {
|
||||
if (parsedScope.errors.length > 0) {
|
||||
toast.error("请修正无效的资产范围");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api.updateCompanyScope(company.id, rules, reason);
|
||||
const res = await api.updateCompanyScope(company.id, parsedScope.rules, reason);
|
||||
const errCount = res.invalid ?? 0;
|
||||
if (errCount > 0) toast.warning(`已保存;${errCount} 行无效`);
|
||||
else toast.success(`范围已更新,共 ${res.added} 条`);
|
||||
@@ -1609,23 +1529,28 @@ function EditScopeDialog({ company, onSaved }: { company: Company; onSaved: () =
|
||||
编辑后将替换全部现有范围。ICP 精确匹配资产,企业关键词仅作为 Agent 提示。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
<ScopeRulesEditor value={scope} onValueChange={setScope} />
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="es-reason">归属依据(可选)</Label>
|
||||
<FieldGroup className="py-2">
|
||||
<ScopeTextEditor
|
||||
id={`edit-company-scope-${company.id}`}
|
||||
value={scopeText}
|
||||
onValueChange={setScopeText}
|
||||
parsed={parsedScope}
|
||||
/>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="es-reason">归属依据(可选)</FieldLabel>
|
||||
<Input
|
||||
id="es-reason"
|
||||
placeholder="如 证书 / whois / ASN 佐证"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={busy}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={busy}>
|
||||
<Button onClick={submit} disabled={busy || parsedScope.errors.length > 0}>
|
||||
{busy ? "保存中…" : "覆盖保存"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -1637,31 +1562,33 @@ function EditScopeDialog({ company, onSaved }: { company: Company; onSaved: () =
|
||||
// 追加资产范围弹窗
|
||||
function AppendScopeDialog({ company, onSaved }: { company: Company; onSaved: () => void }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [scope, setScope] = React.useState<EditableScopeRule[]>(() => [editableScopeRule()]);
|
||||
const [scopeText, setScopeText] = React.useState("");
|
||||
const [reason, setReason] = React.useState("");
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const remainingScopeRules = Math.max(0, MAX_COMPANY_SCOPE_RULES - (company.scope?.length || 0));
|
||||
const parsedScope = React.useMemo(
|
||||
() => parseCompanyScopeText(scopeText, { maxRules: remainingScopeRules }),
|
||||
[remainingScopeRules, scopeText],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setScope([editableScopeRule()]);
|
||||
setScopeText("");
|
||||
setReason("");
|
||||
}, [open]);
|
||||
|
||||
const submit = async () => {
|
||||
const rules: CompanyScopeRule[] = scope
|
||||
.filter((rule) => rule.value.trim())
|
||||
.map((rule) => ({ kind: rule.kind, value: rule.value.trim() }));
|
||||
if (rules.length === 0) {
|
||||
if (parsedScope.rules.length === 0) {
|
||||
toast.error("请填写要追加的范围");
|
||||
return;
|
||||
}
|
||||
if (rules.some(scopeRuleError)) {
|
||||
if (parsedScope.errors.length > 0) {
|
||||
toast.error("请修正无效的资产范围");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api.addCompanyScope(company.id, rules, reason);
|
||||
const res = await api.addCompanyScope(company.id, parsedScope.rules, reason);
|
||||
const errCount = res.invalid ?? 0;
|
||||
if (errCount > 0) toast.warning(`已保存;${errCount} 行无效`);
|
||||
else toast.success(`已追加 ${res.added} 条范围`);
|
||||
@@ -1686,23 +1613,28 @@ function AppendScopeDialog({ company, onSaved }: { company: Company; onSaved: ()
|
||||
<DialogTitle>追加资产范围 · {company.name}</DialogTitle>
|
||||
<DialogDescription>新范围会追加到现有范围。ICP 精确匹配资产,企业关键词仅作为 Agent 提示。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
<ScopeRulesEditor value={scope} onValueChange={setScope} />
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="as-reason">归属依据(可选)</Label>
|
||||
<FieldGroup className="py-2">
|
||||
<ScopeTextEditor
|
||||
id={`append-company-scope-${company.id}`}
|
||||
value={scopeText}
|
||||
onValueChange={setScopeText}
|
||||
parsed={parsedScope}
|
||||
/>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="as-reason">归属依据(可选)</FieldLabel>
|
||||
<Input
|
||||
id="as-reason"
|
||||
placeholder="如 证书 / whois / ASN 佐证"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={busy}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={busy}>
|
||||
<Button onClick={submit} disabled={busy || parsedScope.rules.length === 0 || parsedScope.errors.length > 0}>
|
||||
{busy ? "保存中…" : "追加"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,37 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
TerminalIcon,
|
||||
SearchIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
Loader2Icon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ChevronLeftIcon, ChevronRightIcon, Loader2Icon, SearchIcon, TerminalIcon } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { api } from "@/lib/api";
|
||||
import type { CommandRecord } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function fmtTime(ts: string) {
|
||||
return new Date(ts).toLocaleString("zh-CN", {
|
||||
@@ -50,7 +32,9 @@ function toolInput(raw: string): string {
|
||||
const obj = JSON.parse(raw);
|
||||
if (obj && typeof obj.command === "string") return obj.command;
|
||||
return JSON.stringify(obj, null, 2);
|
||||
} catch { /* not JSON */ }
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
@@ -75,7 +59,7 @@ export default function CommandsPage() {
|
||||
const [total, setTotal] = React.useState(0);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
// Inline detail panel (like traffic page, not a dialog)
|
||||
// Selected execution is rendered in a right-side detail sheet.
|
||||
const [selected, setSelected] = React.useState<CommandRecord | null>(null);
|
||||
|
||||
// Debounce search input.
|
||||
@@ -85,6 +69,7 @@ export default function CommandsPage() {
|
||||
}, [query]);
|
||||
|
||||
// Reset page on filter change.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: every filter change intentionally resets pagination.
|
||||
React.useEffect(() => {
|
||||
setPage(0);
|
||||
}, [queryQ, taskFilter, size]);
|
||||
@@ -100,9 +85,15 @@ export default function CommandsPage() {
|
||||
setCommands(r.commands ?? []);
|
||||
setTotal(r.total ?? 0);
|
||||
})
|
||||
.catch(() => {})
|
||||
.catch(() => {
|
||||
if (!alive) return;
|
||||
setCommands([]);
|
||||
setTotal(0);
|
||||
})
|
||||
.finally(() => alive && setLoading(false));
|
||||
return () => { alive = false; };
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [page, size, queryQ, taskFilter]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / size));
|
||||
@@ -178,8 +169,8 @@ export default function CommandsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* History table + inline detail (Burp-style split) */}
|
||||
<div className="flex h-[calc(100vh-13rem)] min-h-0 flex-col gap-3">
|
||||
{/* History table */}
|
||||
<div className="flex h-[calc(100vh-13rem)] min-h-0 flex-col">
|
||||
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden py-0">
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<Table>
|
||||
@@ -210,18 +201,13 @@ export default function CommandsPage() {
|
||||
commands.map((cmd) => (
|
||||
<TableRow
|
||||
key={cmd.id}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
selected?.id === cmd.id && "bg-accent hover:bg-accent",
|
||||
)}
|
||||
className={cn("cursor-pointer", selected?.id === cmd.id && "bg-accent hover:bg-accent")}
|
||||
onClick={() => setSelected(cmd)}
|
||||
>
|
||||
<TableCell className="text-xs text-muted-foreground tabular-nums">
|
||||
{fmtTime(cmd.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-mono text-muted-foreground">
|
||||
#{cmd.exploration_id}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-mono text-muted-foreground">#{cmd.exploration_id}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs font-mono">
|
||||
{cmd.worker || "-"}
|
||||
@@ -239,9 +225,13 @@ export default function CommandsPage() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{cmd.is_error ? (
|
||||
<Badge variant="destructive" className="text-xs">失败</Badge>
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
失败
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-xs text-emerald-600">成功</Badge>
|
||||
<Badge variant="secondary" className="text-xs text-emerald-600">
|
||||
成功
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -251,67 +241,63 @@ export default function CommandsPage() {
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Inline detail panel */}
|
||||
{selected && (
|
||||
<Card className="flex h-[42%] min-h-0 flex-col overflow-hidden py-0">
|
||||
{/* Detail header */}
|
||||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<Badge variant="outline" className="text-xs font-mono">
|
||||
#{selected.exploration_id}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs font-mono">
|
||||
{selected.worker || "-"}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs font-mono">
|
||||
{selected.tool || "-"}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{fmtTime(selected.created_at)}
|
||||
</span>
|
||||
{selected.is_error ? (
|
||||
<Badge variant="destructive" className="text-xs">失败</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-xs text-emerald-600">成功</Badge>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-auto size-7 shrink-0"
|
||||
onClick={() => setSelected(null)}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Input / Output split */}
|
||||
<div className="grid min-h-0 flex-1 grid-cols-2 divide-x">
|
||||
<div className="flex min-h-0 min-w-0 flex-col">
|
||||
<div className="border-b px-3 py-1 text-[11px] font-medium text-muted-foreground">
|
||||
输入 Input
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<pre className="p-3 font-mono text-xs break-all whitespace-pre-wrap">
|
||||
{toolInput(selected.command)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-0 min-w-0 flex-col">
|
||||
<div className="border-b px-3 py-1 text-[11px] font-medium text-muted-foreground">
|
||||
输出 Output
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<pre className={cn(
|
||||
"p-3 font-mono text-xs break-all whitespace-pre-wrap",
|
||||
selected.is_error && "text-red-600 dark:text-red-400",
|
||||
)}>
|
||||
{selected.output || "(空)"}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Sheet open={selected !== null} onOpenChange={(open) => !open && setSelected(null)}>
|
||||
<SheetContent className="w-full! max-w-none! gap-0 p-0 sm:w-[46rem]! sm:max-w-[46rem]!">
|
||||
{selected && (
|
||||
<>
|
||||
<SheetHeader className="border-b px-5 py-4">
|
||||
<SheetTitle className="pr-8">工具执行详情</SheetTitle>
|
||||
<SheetDescription>{fmtTime(selected.created_at)}</SheetDescription>
|
||||
<div className="flex flex-wrap items-center gap-2 pt-2">
|
||||
<Badge variant="outline" className="text-xs font-mono">
|
||||
#{selected.exploration_id}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs font-mono">
|
||||
{selected.worker || "-"}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs font-mono">
|
||||
{selected.tool || "-"}
|
||||
</Badge>
|
||||
{selected.is_error ? (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
失败
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-xs text-emerald-600">
|
||||
成功
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<div className="grid min-h-0 flex-1 grid-rows-2 divide-y">
|
||||
<div className="flex min-h-0 min-w-0 flex-col">
|
||||
<div className="border-b px-5 py-2 text-xs font-medium text-muted-foreground">输入 Input</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<pre className="p-5 font-mono text-xs break-all whitespace-pre-wrap">
|
||||
{toolInput(selected.command)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-0 min-w-0 flex-col">
|
||||
<div className="border-b px-5 py-2 text-xs font-medium text-muted-foreground">输出 Output</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<pre
|
||||
className={cn(
|
||||
"p-5 font-mono text-xs break-all whitespace-pre-wrap",
|
||||
selected.is_error && "text-red-600 dark:text-red-400",
|
||||
)}
|
||||
>
|
||||
{selected.output || "(空)"}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,12 +6,16 @@ import Link from "next/link";
|
||||
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
BugIcon,
|
||||
ChevronRightIcon,
|
||||
ClockIcon,
|
||||
DownloadIcon,
|
||||
FileTextIcon,
|
||||
FlaskConicalIcon,
|
||||
InfoIcon,
|
||||
ShieldAlertIcon,
|
||||
Trash2Icon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -30,7 +34,6 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -529,13 +532,13 @@ export default function FindingsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const statCards: { label: string; value: number; tone?: string }[] = [
|
||||
{ label: "发现总数", value: stats.total },
|
||||
{ label: "待处理", value: stats.pending, tone: "text-amber-500" },
|
||||
{ label: "严重", value: stats.critical, tone: "text-rose-600" },
|
||||
{ label: "高危", value: stats.high, tone: "text-red-500" },
|
||||
{ label: "中危", value: stats.medium, tone: "text-amber-500" },
|
||||
{ label: "低危", value: stats.low, tone: "text-slate-500" },
|
||||
const statCards = [
|
||||
{ label: "发现总数", value: stats.total, icon: BugIcon },
|
||||
{ label: "待处理", value: stats.pending, tone: "text-amber-500", icon: ClockIcon },
|
||||
{ label: "严重", value: stats.critical, tone: "text-rose-600", icon: ShieldAlertIcon },
|
||||
{ label: "高危", value: stats.high, tone: "text-red-500", icon: TriangleAlertIcon },
|
||||
{ label: "中危", value: stats.medium, tone: "text-amber-500", icon: TriangleAlertIcon },
|
||||
{ label: "低危", value: stats.low, tone: "text-slate-500", icon: InfoIcon },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -546,14 +549,20 @@ export default function FindingsPage() {
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-4 md:gap-6">
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{statCards.map((s) => (
|
||||
<Card key={s.label} className="gap-1 py-4">
|
||||
<CardHeader className="px-4">
|
||||
<CardDescription>{s.label}</CardDescription>
|
||||
<CardTitle className={cn("text-2xl tabular-nums", s.tone)}>{s.value}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
{statCards.map((stat) => {
|
||||
const StatIcon = stat.icon;
|
||||
return (
|
||||
<Card key={stat.label} className="gap-1 py-4">
|
||||
<CardHeader className="px-4">
|
||||
<CardDescription>{stat.label}</CardDescription>
|
||||
<CardTitle className={cn("flex items-center gap-2 text-2xl tabular-nums", stat.tone)}>
|
||||
<StatIcon className="size-5" aria-hidden="true" />
|
||||
{stat.value}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -624,7 +633,10 @@ export default function FindingsPage() {
|
||||
<span className="max-w-[14rem] truncate" title={label}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-muted-foreground tabular-nums">{t.count}</span>
|
||||
<span className="inline-flex items-center gap-1 text-muted-foreground tabular-nums">
|
||||
<BugIcon className="size-3.5" aria-hidden="true" />
|
||||
{t.count}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
@@ -646,7 +658,6 @@ export default function FindingsPage() {
|
||||
{selectedIds.size > 0 && (
|
||||
<span className="text-xs text-muted-foreground tabular-nums">已选 {selectedIds.size} 条</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground tabular-nums">共 {total} 条</span>
|
||||
<Button size="sm" variant="outline" onClick={openExport}>
|
||||
<DownloadIcon /> 导出
|
||||
</Button>
|
||||
@@ -712,9 +723,6 @@ export default function FindingsPage() {
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
共 {group.count} 条
|
||||
</Badge>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{fmtTime(group.last_found_at)}</span>
|
||||
{group.task_id !== null && (
|
||||
<Button size="icon-sm" variant="ghost" asChild>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
ArrowUpIcon,
|
||||
BrainIcon,
|
||||
CircleCheckIcon,
|
||||
CircleSlashIcon,
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
PlayIcon,
|
||||
RadioIcon,
|
||||
RotateCwIcon,
|
||||
SendIcon,
|
||||
ShieldAlertIcon,
|
||||
SquareIcon,
|
||||
Trash2Icon,
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -48,7 +47,6 @@ import { api, sseUrl } from "@/lib/api";
|
||||
import { MOCK } from "@/lib/mock/enabled";
|
||||
import type {
|
||||
Activity,
|
||||
BatchControlItem,
|
||||
ChatAttachment,
|
||||
InterceptApprovalRow,
|
||||
Session,
|
||||
@@ -163,6 +161,36 @@ function fmtTokens(n: number): string {
|
||||
return String(n);
|
||||
}
|
||||
|
||||
const TokenMetrics = React.forwardRef<
|
||||
HTMLSpanElement,
|
||||
React.ComponentPropsWithoutRef<"span"> & {
|
||||
input: number;
|
||||
cache: number;
|
||||
output: number;
|
||||
labels?: "short" | "long";
|
||||
}
|
||||
>(({ input, cache, output, labels = "short", className, ...props }, ref) => {
|
||||
const names = labels === "short" ? ["入", "缓", "出"] : ["input", "cache", "output"];
|
||||
const values = [input, cache, output];
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={cn("inline-flex min-w-0 flex-wrap items-center gap-1 text-muted-foreground", className)}
|
||||
{...props}
|
||||
>
|
||||
{values.map((value, index) => (
|
||||
<React.Fragment key={names[index]}>
|
||||
<span>{names[index]}</span>
|
||||
<Badge variant="secondary" className="h-5 px-1.5 font-mono tabular-nums">
|
||||
{fmtTokens(value)}
|
||||
</Badge>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
TokenMetrics.displayName = "TokenMetrics";
|
||||
|
||||
// fmtDuration renders an elapsed milliseconds span compactly (90s → 1m30s).
|
||||
function fmtDuration(ms: number): string {
|
||||
const s = Math.floor(ms / 1000);
|
||||
@@ -277,12 +305,6 @@ function SessionItem({
|
||||
onResume,
|
||||
onCancel,
|
||||
controlling,
|
||||
resolution,
|
||||
tokens,
|
||||
selectable,
|
||||
selected,
|
||||
onSelectedChange,
|
||||
selectionDisabled,
|
||||
}: {
|
||||
s: Session;
|
||||
active: boolean;
|
||||
@@ -294,16 +316,7 @@ function SessionItem({
|
||||
onResume?: () => void;
|
||||
onCancel?: () => void;
|
||||
controlling?: boolean;
|
||||
resolution?: TaskLLMResolution;
|
||||
tokens?: TokenTotal;
|
||||
selectable?: boolean;
|
||||
selected?: boolean;
|
||||
onSelectedChange?: (checked: boolean) => void;
|
||||
selectionDisabled?: boolean;
|
||||
}) {
|
||||
const tokenCount = tokens
|
||||
? tokens.input_tokens + tokens.output_tokens + tokens.cache_read_tokens + tokens.cache_write_tokens
|
||||
: 0;
|
||||
const icon =
|
||||
s.role === "worker" ? (
|
||||
statusIcon(s.status)
|
||||
@@ -319,16 +332,6 @@ function SessionItem({
|
||||
active ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{selectable && (
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onCheckedChange={(value) => onSelectedChange?.(value === true)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
disabled={selectionDisabled}
|
||||
className="ml-1 shrink-0"
|
||||
aria-label={`选择 Worker ${s.intent_id}`}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
@@ -345,32 +348,7 @@ function SessionItem({
|
||||
来源 #{s.source_task_id}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">{displayTitle}</span>
|
||||
{resolution && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="outline" className="min-w-0 max-w-24 shrink-0">
|
||||
<span className="min-w-0 truncate">{resolution.available ? resolution.model : "模型不可用"}</span>
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-xs [overflow-wrap:anywhere]">
|
||||
{resolution.available
|
||||
? `${resolution.name} / ${resolution.format} / ${resolution.model}`
|
||||
: resolution.reason || "没有可用的 LLM 配置"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{tokens && tokenCount > 0 && s.role === "worker" && !s.inherited && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge className="shrink-0 font-mono tabular-nums">{fmtTokens(tokenCount)}</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
输入 {tokens.input_tokens.toLocaleString()} · 输出 {tokens.output_tokens.toLocaleString()} · 缓存读取{" "}
|
||||
{tokens.cache_read_tokens.toLocaleString()} · 缓存写入 {tokens.cache_write_tokens.toLocaleString()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">{displayTitle}</span>
|
||||
{hasPending && <ShieldAlertIcon className="size-3.5 shrink-0 text-amber-500" />}
|
||||
{!active && unread ? (
|
||||
<span className="inline-flex min-w-4 items-center justify-center rounded-full bg-blue-500/15 px-1 text-[10px] font-medium tabular-nums text-blue-600 dark:text-blue-400">
|
||||
@@ -446,8 +424,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
const [mainChatRunning, setMainChatRunning] = React.useState<boolean | null>(null);
|
||||
const [controllingIntent, setControllingIntent] = React.useState<string | null>(null);
|
||||
const [cancelIntent, setCancelIntent] = React.useState<Session | null>(null);
|
||||
const [selectedIntentIds, setSelectedIntentIds] = React.useState<Set<string>>(() => new Set());
|
||||
const [batchControlling, setBatchControlling] = React.useState<"pause" | "resume" | null>(null);
|
||||
// 方式1 文件上传:选好的附件(已落到任务工作目录 uploads/),随下条消息一起发。
|
||||
const [attachments, setAttachments] = React.useState<ChatAttachment[]>([]);
|
||||
const [uploading, setUploading] = React.useState(false);
|
||||
@@ -478,7 +454,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
|
||||
const controlWorker = React.useCallback(
|
||||
async (session: Session, action: "pause" | "resume" | "cancel") => {
|
||||
if (!session.intent_id || session.inherited || controllingIntent || batchControlling) return;
|
||||
if (!session.intent_id || session.inherited || controllingIntent) return;
|
||||
setControllingIntent(session.intent_id);
|
||||
try {
|
||||
const result = await api.controlIntent(taskId, session.intent_id, action);
|
||||
@@ -505,7 +481,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
setCancelIntent(null);
|
||||
}
|
||||
},
|
||||
[activeId, batchControlling, controllingIntent, patchIntentState, taskId],
|
||||
[activeId, controllingIntent, patchIntentState, taskId],
|
||||
);
|
||||
// SSE connection state — surfaced so a dropped realtime link is visible, never
|
||||
// silently shown as "no messages".
|
||||
@@ -758,7 +734,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
setFirstIntentsHasMore(false);
|
||||
setOlderIntentsHasMore(false);
|
||||
setHasLoadedOlderIntentsPage(false);
|
||||
setSelectedIntentIds(new Set());
|
||||
setTaskTokens(null);
|
||||
setSessionTokens({});
|
||||
setLLMResolutions(null);
|
||||
@@ -978,77 +953,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
const workerSessions = React.useMemo(() => allIntents.map(intentToSession), [allIntents]);
|
||||
const intentsHasMore = hasLoadedOlderIntentsPage ? olderIntentsHasMore : firstIntentsHasMore;
|
||||
|
||||
const selectableIntentIds = React.useMemo(
|
||||
() =>
|
||||
allIntents
|
||||
.filter((node) => !node.inherited && (node.state === "running" || node.state === "paused"))
|
||||
.map((node) => node.id),
|
||||
[allIntents],
|
||||
);
|
||||
React.useEffect(() => {
|
||||
const allowed = new Set(selectableIntentIds);
|
||||
setSelectedIntentIds((current) => {
|
||||
const next = new Set([...current].filter((id) => allowed.has(id)));
|
||||
return next.size === current.size ? current : next;
|
||||
});
|
||||
}, [selectableIntentIds]);
|
||||
|
||||
const toggleIntentSelection = React.useCallback((id: string, checked: boolean) => {
|
||||
setSelectedIntentIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (checked) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const batchControlWorkers = React.useCallback(
|
||||
async (action: "pause" | "resume") => {
|
||||
const requiredState = action === "pause" ? "running" : "paused";
|
||||
const ids = allIntents
|
||||
.filter((node) => selectedIntentIds.has(node.id) && !node.inherited && node.state === requiredState)
|
||||
.map((node) => node.id);
|
||||
if (ids.length === 0 || batchControlling) return;
|
||||
setBatchControlling(action);
|
||||
try {
|
||||
const items: BatchControlItem[] = [];
|
||||
const requestErrors: Error[] = [];
|
||||
for (let offset = 0; offset < ids.length; offset += 100) {
|
||||
const chunk = ids.slice(offset, offset + 100);
|
||||
try {
|
||||
const response = await api.controlIntentsBatch(taskId, chunk, action);
|
||||
items.push(...response.items);
|
||||
} catch (error) {
|
||||
requestErrors.push(error as Error);
|
||||
items.push(...chunk.map((id) => ({ id, ok: false, error: (error as Error).message })));
|
||||
}
|
||||
}
|
||||
const succeeded = items.filter((item) => item.ok);
|
||||
const failed = items.length - succeeded.length;
|
||||
for (const item of succeeded) {
|
||||
patchIntentState(item.id, item.state ?? (action === "pause" ? "paused" : "open"));
|
||||
}
|
||||
setSelectedIntentIds((current) => {
|
||||
const next = new Set(current);
|
||||
for (const item of succeeded) next.delete(item.id);
|
||||
return next;
|
||||
});
|
||||
if (succeeded.length > 0)
|
||||
toast.success(`已${action === "pause" ? "暂停" : "继续"} ${succeeded.length} 个 Worker`);
|
||||
if (requestErrors.length > 0) {
|
||||
toast.error(`${requestErrors.length} 组批量请求失败,${failed} 个 Worker 未执行`);
|
||||
} else if (failed > 0) {
|
||||
toast.warning(`${failed} 个 Worker 状态已变化,未执行`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`批量操作失败:${(error as Error).message}`);
|
||||
} finally {
|
||||
setBatchControlling(null);
|
||||
}
|
||||
},
|
||||
[allIntents, batchControlling, patchIntentState, selectedIntentIds, taskId],
|
||||
);
|
||||
|
||||
// For each worker session (intent), derive the display title from the intent
|
||||
// payload summary. Also store the full TaskNode for the hover-JSON tooltip.
|
||||
const sessionMeta = React.useMemo(() => {
|
||||
@@ -1217,7 +1121,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
i: activeTokens.input_tokens,
|
||||
o: activeTokens.output_tokens,
|
||||
cr: activeTokens.cache_read_tokens,
|
||||
cw: activeTokens.cache_write_tokens,
|
||||
any:
|
||||
activeTokens.input_tokens +
|
||||
activeTokens.output_tokens +
|
||||
@@ -1331,25 +1234,13 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
const mainLoaded = !!store.main?.loaded;
|
||||
// What the transcript pane should show for the active session.
|
||||
const showLoader = !activeState || (activeState.loading && !activeState.loaded);
|
||||
const selectedPauseCount = allIntents.filter(
|
||||
(node) => selectedIntentIds.has(node.id) && !node.inherited && node.state === "running",
|
||||
).length;
|
||||
const selectedResumeCount = allIntents.filter(
|
||||
(node) => selectedIntentIds.has(node.id) && !node.inherited && node.state === "paused",
|
||||
).length;
|
||||
const selectedSelectableCount = selectableIntentIds.filter((id) => selectedIntentIds.has(id)).length;
|
||||
let allSelectableChecked: boolean | "indeterminate" = false;
|
||||
if (selectableIntentIds.length > 0 && selectedSelectableCount === selectableIntentIds.length) {
|
||||
allSelectableChecked = true;
|
||||
} else if (selectedSelectableCount > 0) {
|
||||
allSelectableChecked = "indeterminate";
|
||||
}
|
||||
const resolutionForSession = (session: Session): TaskLLMResolution | undefined => {
|
||||
if (!llmResolutions || session.inherited || session.role === "system") return undefined;
|
||||
if (session.role === "mainagent") return llmResolutions.mainagent;
|
||||
if (session.role === "planner") return llmResolutions.planner;
|
||||
return llmResolutions.worker;
|
||||
};
|
||||
const activeResolution = resolutionForSession(active);
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
@@ -1384,10 +1275,15 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
{taskTokens && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge className="mt-2 w-full min-w-0 justify-start font-mono tabular-nums">
|
||||
任务总计 · 入 {fmtTokens(taskTokens.input_tokens)} · 缓 {fmtTokens(taskTokens.cache_read_tokens)} ·
|
||||
出 {fmtTokens(taskTokens.output_tokens)}
|
||||
</Badge>
|
||||
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-1 text-xs text-muted-foreground">
|
||||
<span>任务总计</span>
|
||||
<span>·</span>
|
||||
<TokenMetrics
|
||||
input={taskTokens.input_tokens}
|
||||
cache={taskTokens.cache_read_tokens}
|
||||
output={taskTokens.output_tokens}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
输入 {taskTokens.input_tokens.toLocaleString()} · 输出 {taskTokens.output_tokens.toLocaleString()} ·
|
||||
@@ -1396,35 +1292,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{selectedIntentIds.size > 0 && (
|
||||
<div className="mt-2 flex items-center gap-1">
|
||||
<span className="min-w-0 flex-1 text-xs text-muted-foreground">
|
||||
已选 {selectedIntentIds.size} 个 Worker
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
disabled={selectedPauseCount === 0 || batchControlling !== null}
|
||||
onClick={() => void batchControlWorkers("pause")}
|
||||
title={`暂停 ${selectedPauseCount} 个运行中 Worker`}
|
||||
aria-label="批量暂停 Worker"
|
||||
>
|
||||
{batchControlling === "pause" ? <Loader2Icon className="animate-spin" /> : <PauseIcon />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
disabled={selectedResumeCount === 0 || batchControlling !== null}
|
||||
onClick={() => void batchControlWorkers("resume")}
|
||||
title={`继续 ${selectedResumeCount} 个已暂停 Worker`}
|
||||
aria-label="批量继续 Worker"
|
||||
>
|
||||
{batchControlling === "resume" ? <Loader2Icon className="animate-spin" /> : <PlayIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ScrollArea type="auto" className="min-h-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!">
|
||||
<div className="flex w-full flex-col gap-3 p-2">
|
||||
@@ -1435,23 +1302,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
return (
|
||||
<div key={role} className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||
{role === "worker" && selectableIntentIds.length > 0 && (
|
||||
<Checkbox
|
||||
checked={allSelectableChecked}
|
||||
onCheckedChange={(value) => {
|
||||
const checked = value === true;
|
||||
setSelectedIntentIds((current) => {
|
||||
const next = new Set(current);
|
||||
for (const id of selectableIntentIds) {
|
||||
if (checked) next.add(id);
|
||||
else next.delete(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
aria-label="选择全部可控制 Worker"
|
||||
/>
|
||||
)}
|
||||
<Meta.icon className="size-3.5" />
|
||||
{Meta.label}
|
||||
</div>
|
||||
@@ -1466,19 +1316,11 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
hasPending={hasPendingForSession(s)}
|
||||
unread={store[keyForSession(s)]?.unread}
|
||||
onClick={() => setActiveId(s.id)}
|
||||
controlling={batchControlling !== null || controllingIntent === s.intent_id}
|
||||
resolution={resolutionForSession(s)}
|
||||
tokens={tokenForSession(keyForSession(s))}
|
||||
selectable={
|
||||
s.role === "worker" && !s.inherited && (s.status === "running" || s.status === "paused")
|
||||
}
|
||||
selected={selectedIntentIds.has(s.intent_id ?? "")}
|
||||
selectionDisabled={batchControlling !== null}
|
||||
onSelectedChange={(checked) => s.intent_id && toggleIntentSelection(s.intent_id, checked)}
|
||||
controlling={controllingIntent === s.intent_id}
|
||||
onPause={() => void controlWorker(s, "pause")}
|
||||
onResume={() => void controlWorker(s, "resume")}
|
||||
onCancel={() => {
|
||||
if (!batchControlling) setCancelIntent(s);
|
||||
setCancelIntent(s);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -1529,6 +1371,26 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
titleEl
|
||||
);
|
||||
})()}
|
||||
{activeResolution && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="max-w-28 shrink-0 font-mono font-normal"
|
||||
aria-label={activeResolution.available ? `当前模型:${activeResolution.model}` : "模型不可用"}
|
||||
>
|
||||
<span className="truncate">
|
||||
{activeResolution.available ? activeResolution.model : "模型不可用"}
|
||||
</span>
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs [overflow-wrap:anywhere]">
|
||||
{activeResolution.available
|
||||
? `${activeResolution.name} / ${activeResolution.format} / ${activeResolution.model}`
|
||||
: activeResolution.reason || "没有可用的 LLM 配置"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{active.inherited && active.source_task_id && (
|
||||
<Badge variant="outline">来源任务 #{active.source_task_id} · 只读历史</Badge>
|
||||
)}
|
||||
@@ -1545,9 +1407,17 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
)}
|
||||
<div className="ml-auto flex min-w-0 max-w-full items-center justify-end gap-x-3 gap-y-1 text-xs text-muted-foreground max-sm:w-full max-sm:flex-wrap">
|
||||
{tokenTotal.any && (
|
||||
<span className="min-w-0 truncate" title="input / cache(read) / output tokens">
|
||||
input {fmtTokens(tokenTotal.i)} · cache {fmtTokens(tokenTotal.cr)} · output {fmtTokens(tokenTotal.o)}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<TokenMetrics input={tokenTotal.i} cache={tokenTotal.cr} output={tokenTotal.o} labels="long" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
输入 {activeTokens.input_tokens.toLocaleString()} · 输出{" "}
|
||||
{activeTokens.output_tokens.toLocaleString()} · 缓存读取{" "}
|
||||
{activeTokens.cache_read_tokens.toLocaleString()} · 缓存写入{" "}
|
||||
{activeTokens.cache_write_tokens.toLocaleString()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{runDuration != null && (
|
||||
<span className="inline-flex items-center gap-1" title="运行时长(首步 → 末步)">
|
||||
@@ -1673,7 +1543,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
title="发送消息"
|
||||
aria-label="发送消息"
|
||||
>
|
||||
{sending ? <Loader2Icon className="animate-spin" /> : <SendIcon />}
|
||||
{sending ? <Loader2Icon className="animate-spin" /> : <ArrowUpIcon />}
|
||||
</InputGroupButton>
|
||||
)}
|
||||
</InputGroupAddon>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
RadioTowerIcon,
|
||||
SearchIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
XIcon,
|
||||
Loader2Icon,
|
||||
Trash2Icon,
|
||||
ListChecksIcon,
|
||||
Loader2Icon,
|
||||
RadioTowerIcon,
|
||||
SearchIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
@@ -22,35 +22,19 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { api } from "@/lib/api";
|
||||
import type { TrafficExchange, TrafficResp, TrafficDetail, TrafficHost } from "@/lib/types";
|
||||
import type { TrafficDetail, TrafficExchange, TrafficHost, TrafficResp } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function fmtTime(ts: string) {
|
||||
return new Date(ts).toLocaleString("zh-CN", {
|
||||
@@ -66,7 +50,7 @@ function fmtBytes(n: number) {
|
||||
if (n <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB"];
|
||||
const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), units.length - 1);
|
||||
const v = n / Math.pow(1024, i);
|
||||
const v = n / 1024 ** i;
|
||||
return `${i === 0 ? v : v.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
@@ -78,6 +62,141 @@ function statusTone(status: number) {
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
function MethodBadge({ method }: { method: string }) {
|
||||
return <Badge className="shrink-0 font-mono">{method}</Badge>;
|
||||
}
|
||||
|
||||
// Older captures may predate Host persistence because net/http keeps Host
|
||||
// outside Request.Header. Fill it for display while newly recorded traffic is
|
||||
// fixed at the recorder layer as well.
|
||||
function requestWithHost(raw: string, exchange: TrafficExchange): string {
|
||||
if (!raw.trim() || /^host\s*:/im.test(raw)) return raw;
|
||||
let host = exchange.host;
|
||||
try {
|
||||
host = new URL(exchange.url).host || host;
|
||||
} catch {
|
||||
// Relative or legacy URLs fall back to the indexed host.
|
||||
}
|
||||
const newline = raw.includes("\r\n") ? "\r\n" : "\n";
|
||||
const firstLineEnd = raw.indexOf(newline);
|
||||
if (firstLineEnd < 0) return `${raw}${newline}Host: ${host}`;
|
||||
return `${raw.slice(0, firstLineEnd + newline.length)}Host: ${host}${newline}${raw.slice(firstLineEnd + newline.length)}`;
|
||||
}
|
||||
|
||||
function StartLine({ line }: { line: string }) {
|
||||
const response = /^(HTTP\/\S+)(\s+)(\d{3})(.*)$/.exec(line);
|
||||
if (response) {
|
||||
return (
|
||||
<>
|
||||
<span className="text-muted-foreground">{response[1]}</span>
|
||||
{response[2]}
|
||||
<span className={statusTone(Number(response[3]))}>{response[3]}</span>
|
||||
{response[4]}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const request = /^([A-Z]+)(\s+)(\S+)(\s+)(HTTP\/\S+)$/.exec(line);
|
||||
if (request) {
|
||||
return (
|
||||
<>
|
||||
<span className="font-semibold text-primary">{request[1]}</span>
|
||||
{request[2]}
|
||||
<span className="text-chart-2">{request[3]}</span>
|
||||
{request[4]}
|
||||
<span className="text-muted-foreground">{request[5]}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return line;
|
||||
}
|
||||
|
||||
function HeaderLine({ line }: { line: string }) {
|
||||
const separator = line.indexOf(":");
|
||||
if (separator <= 0) return line;
|
||||
return (
|
||||
<>
|
||||
<span className="text-primary">{line.slice(0, separator)}</span>
|
||||
<span className="text-muted-foreground">:</span>
|
||||
<span className="text-chart-2">{line.slice(separator + 1)}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function JsonBody({ body }: { body: string }) {
|
||||
const parts: React.ReactNode[] = [];
|
||||
const tokens = /("(?:\\.|[^"\\])*")(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g;
|
||||
let cursor = 0;
|
||||
for (const match of body.matchAll(tokens)) {
|
||||
const index = match.index ?? 0;
|
||||
if (index > cursor) parts.push(body.slice(cursor, index));
|
||||
let className = "text-chart-4";
|
||||
if (match[1]) className = match[2] ? "text-primary" : "text-chart-2";
|
||||
else if (match[3]) className = "text-chart-3";
|
||||
parts.push(
|
||||
<span key={`${index}-${match[0].length}`} className={className}>
|
||||
{match[0]}
|
||||
</span>,
|
||||
);
|
||||
cursor = index + match[0].length;
|
||||
}
|
||||
if (cursor < body.length) parts.push(body.slice(cursor));
|
||||
return parts;
|
||||
}
|
||||
|
||||
function MarkupBody({ body }: { body: string }) {
|
||||
const parts: React.ReactNode[] = [];
|
||||
const tags = /<\/?[A-Za-z][^>]*>|<!--[\s\S]*?-->/g;
|
||||
let cursor = 0;
|
||||
for (const match of body.matchAll(tags)) {
|
||||
const index = match.index ?? 0;
|
||||
if (index > cursor) parts.push(body.slice(cursor, index));
|
||||
parts.push(
|
||||
<span key={`${index}-${match[0].length}`} className="text-primary">
|
||||
{match[0]}
|
||||
</span>,
|
||||
);
|
||||
cursor = index + match[0].length;
|
||||
}
|
||||
if (cursor < body.length) parts.push(body.slice(cursor));
|
||||
return parts;
|
||||
}
|
||||
|
||||
function HighlightedBody({ body }: { body: string }) {
|
||||
const trimmed = body.trimStart();
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) return <JsonBody body={body} />;
|
||||
if (trimmed.startsWith("<")) return <MarkupBody body={body} />;
|
||||
return body;
|
||||
}
|
||||
|
||||
function HttpCodeBlock({ raw }: { raw: string }) {
|
||||
const value = raw || "(空)";
|
||||
const lines = value.replaceAll("\r\n", "\n").split("\n");
|
||||
const separator = lines.indexOf("");
|
||||
const headerLines = separator >= 0 ? lines.slice(0, separator) : lines;
|
||||
const body = separator >= 0 ? lines.slice(separator + 1).join("\n") : "";
|
||||
|
||||
return (
|
||||
<pre className="min-w-full whitespace-pre p-5 font-mono text-xs leading-relaxed">
|
||||
<code>
|
||||
{headerLines.map((line, index) => (
|
||||
<React.Fragment key={`${index}-${line}`}>
|
||||
{index === 0 ? <StartLine line={line} /> : <HeaderLine line={line} />}
|
||||
{index < headerLines.length - 1 ? "\n" : null}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{separator >= 0 && (
|
||||
<>
|
||||
{"\n\n"}
|
||||
<HighlightedBody body={body} />
|
||||
</>
|
||||
)}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
// Fixed method set (server filters exact-match); avoids deriving options from a
|
||||
// single page, which would only ever list the methods on that page.
|
||||
const METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"];
|
||||
@@ -116,12 +235,14 @@ export default function TrafficPage() {
|
||||
}, [query]);
|
||||
|
||||
// Any filter/size change resets to the first page.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: these values intentionally trigger a page reset.
|
||||
React.useEffect(() => {
|
||||
setPage(0);
|
||||
}, [hostQ, queryQ, method, size]);
|
||||
|
||||
// Load the current page. Auto-refresh only on page 0 (newest) so paging back
|
||||
// through history isn't yanked out from under the user.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reloadTick is an explicit manual-refetch trigger.
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
const load = () => {
|
||||
@@ -130,13 +251,17 @@ export default function TrafficPage() {
|
||||
.then((r) => {
|
||||
if (alive) setTraffic(r);
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => {
|
||||
// Keep the last successful snapshot during transient refresh failures.
|
||||
});
|
||||
api
|
||||
.trafficHosts()
|
||||
.then((r) => {
|
||||
if (alive) setHosts(r.hosts ?? []);
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => {
|
||||
// Keep the last successful host list during transient refresh failures.
|
||||
});
|
||||
};
|
||||
load();
|
||||
const t = setInterval(() => {
|
||||
@@ -155,22 +280,21 @@ export default function TrafficPage() {
|
||||
|
||||
const confirmDelete = () => {
|
||||
setDeleting(true);
|
||||
const p =
|
||||
deleteMode === "selected"
|
||||
? api.trafficDeleteHosts(selectedHosts)
|
||||
: api.trafficDeleteHost(hostQ);
|
||||
const p = deleteMode === "selected" ? api.trafficDeleteHosts(selectedHosts) : api.trafficDeleteHost(hostQ);
|
||||
p.then(() => {
|
||||
setDeleteMode(null);
|
||||
setSelected(null);
|
||||
setDetail(null);
|
||||
if (deleteMode === "selected") {
|
||||
setSelectedHosts([]);
|
||||
setPickerOpen(false);
|
||||
}
|
||||
setPage(0);
|
||||
setReloadTick((t) => t + 1);
|
||||
setDeleteMode(null);
|
||||
setSelected(null);
|
||||
setDetail(null);
|
||||
if (deleteMode === "selected") {
|
||||
setSelectedHosts([]);
|
||||
setPickerOpen(false);
|
||||
}
|
||||
setPage(0);
|
||||
setReloadTick((t) => t + 1);
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep the confirmation open so the user can retry a failed deletion.
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setDeleting(false));
|
||||
};
|
||||
|
||||
@@ -210,9 +334,7 @@ export default function TrafficPage() {
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">流量</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
全局录制代理 · 所有 HTTP 往来
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">全局录制代理 · 所有 HTTP 往来</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span
|
||||
@@ -226,11 +348,7 @@ export default function TrafficPage() {
|
||||
<RadioTowerIcon className="size-3.5" />
|
||||
{traffic?.enabled ? "录制中" : "已停用"}
|
||||
</span>
|
||||
{traffic?.proxy && (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{traffic.proxy}
|
||||
</span>
|
||||
)}
|
||||
{traffic?.proxy && <span className="font-mono text-xs text-muted-foreground">{traffic.proxy}</span>}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
共 <span className="tabular-nums">{traffic?.count ?? 0}</span> 条
|
||||
</span>
|
||||
@@ -266,29 +384,25 @@ export default function TrafficPage() {
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{hosts.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
暂无流量记录
|
||||
</div>
|
||||
<div className="px-3 py-6 text-center text-xs text-muted-foreground">暂无流量记录</div>
|
||||
) : (
|
||||
hosts.map((h) => (
|
||||
hosts.map((h, index) => (
|
||||
<label
|
||||
key={h.host}
|
||||
htmlFor={`traffic-host-${index}`}
|
||||
className="flex cursor-pointer items-center gap-2 px-3 py-1.5 text-xs hover:bg-accent"
|
||||
>
|
||||
<Checkbox
|
||||
id={`traffic-host-${index}`}
|
||||
checked={selectedHosts.includes(h.host)}
|
||||
onCheckedChange={() =>
|
||||
setSelectedHosts((prev) =>
|
||||
prev.includes(h.host)
|
||||
? prev.filter((x) => x !== h.host)
|
||||
: [...prev, h.host],
|
||||
prev.includes(h.host) ? prev.filter((x) => x !== h.host) : [...prev, h.host],
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="truncate font-mono">{h.host}</span>
|
||||
<span className="ml-auto shrink-0 tabular-nums text-muted-foreground">
|
||||
{h.count}
|
||||
</span>
|
||||
<span className="ml-auto shrink-0 tabular-nums text-muted-foreground">{h.count}</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
@@ -310,12 +424,7 @@ export default function TrafficPage() {
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className="relative w-48">
|
||||
<Input
|
||||
placeholder="host…"
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
<Input placeholder="host…" value={host} onChange={(e) => setHost(e.target.value)} className="h-8" />
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@@ -391,8 +500,8 @@ export default function TrafficPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* History table + detail (Burp-style split) */}
|
||||
<div className="flex h-[calc(100vh-15rem)] min-h-0 flex-col gap-3">
|
||||
{/* History table */}
|
||||
<div className="flex h-[calc(100vh-15rem)] min-h-0 flex-col">
|
||||
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden py-0">
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<Table>
|
||||
@@ -411,50 +520,29 @@ export default function TrafficPage() {
|
||||
{exchanges.map((e) => (
|
||||
<TableRow
|
||||
key={e.id}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
selected?.id === e.id && "bg-accent hover:bg-accent",
|
||||
)}
|
||||
className={cn("cursor-pointer", selected?.id === e.id && "bg-accent hover:bg-accent")}
|
||||
onClick={() => setSelected(e)}
|
||||
>
|
||||
<TableCell className="text-xs text-muted-foreground tabular-nums">
|
||||
{fmtTime(e.ts)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground tabular-nums">{fmtTime(e.ts)}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{e.host}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{e.method}
|
||||
</Badge>
|
||||
<MethodBadge method={e.method} />
|
||||
</TableCell>
|
||||
<TableCell className="max-w-0">
|
||||
<span className="block truncate font-mono text-xs">
|
||||
{e.url}
|
||||
</span>
|
||||
<span className="block truncate font-mono text-xs">{e.url}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-xs font-semibold tabular-nums",
|
||||
statusTone(e.status),
|
||||
)}
|
||||
>
|
||||
<span className={cn("font-mono text-xs font-semibold tabular-nums", statusTone(e.status))}>
|
||||
{e.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{e.content_type}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs tabular-nums">
|
||||
{fmtBytes(e.resp_len)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{e.content_type}</TableCell>
|
||||
<TableCell className="text-right text-xs tabular-nums">{fmtBytes(e.resp_len)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{exchanges.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={7}
|
||||
className="py-12 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
<TableCell colSpan={7} className="py-12 text-center text-sm text-muted-foreground">
|
||||
{traffic === null ? "加载中…" : "没有匹配的流量。"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -463,66 +551,60 @@ export default function TrafficPage() {
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Raw request/response pane */}
|
||||
{selected && (
|
||||
<Card className="flex h-[42%] min-h-0 flex-col overflow-hidden py-0">
|
||||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{selected.method}
|
||||
</Badge>
|
||||
<span className="truncate font-mono text-xs">
|
||||
{selected.host}
|
||||
{selected.url}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto font-mono text-xs font-semibold tabular-nums",
|
||||
statusTone(selected.status),
|
||||
)}
|
||||
>
|
||||
{selected.status}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
onClick={() => setSelected(null)}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid min-h-0 flex-1 grid-cols-2 divide-x">
|
||||
{(
|
||||
[
|
||||
["请求 Request", detail?.req],
|
||||
["响应 Response", detail?.resp],
|
||||
] as const
|
||||
).map(([label, body]) => (
|
||||
<div key={label} className="flex min-h-0 min-w-0 flex-col">
|
||||
<div className="border-b px-3 py-1 text-[11px] font-medium text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center gap-2 p-3 text-xs text-muted-foreground">
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
加载报文…
|
||||
</div>
|
||||
) : (
|
||||
<pre className="p-3 font-mono text-xs break-all whitespace-pre-wrap">
|
||||
{body || "(空)"}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog open={deleteMode !== null} onOpenChange={(o) => { if (!o) setDeleteMode(null); }}>
|
||||
<Sheet open={selected !== null} onOpenChange={(open) => !open && setSelected(null)}>
|
||||
<SheetContent className="w-full! max-w-none! gap-0 p-0 sm:w-[48rem]! sm:max-w-[48rem]!">
|
||||
{selected && (
|
||||
<>
|
||||
<SheetHeader className="border-b px-5 py-4">
|
||||
<div className="flex items-center gap-2 pr-8">
|
||||
<MethodBadge method={selected.method} />
|
||||
<Badge variant="secondary" className={cn("font-mono tabular-nums", statusTone(selected.status))}>
|
||||
{selected.status}
|
||||
</Badge>
|
||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">{fmtTime(selected.ts)}</span>
|
||||
</div>
|
||||
<SheetTitle className="break-all font-mono">{selected.host}</SheetTitle>
|
||||
<SheetDescription className="break-all font-mono">{selected.url}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<Tabs defaultValue="request" className="min-h-0 flex-1 gap-0">
|
||||
<TabsList className="mx-5 mt-4 grid w-auto grid-cols-2">
|
||||
<TabsTrigger value="request">请求 Request</TabsTrigger>
|
||||
<TabsTrigger value="response">响应 Response</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="request" className="min-h-0 overflow-auto">
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center gap-2 p-5 text-xs text-muted-foreground">
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
加载报文…
|
||||
</div>
|
||||
) : (
|
||||
<HttpCodeBlock raw={requestWithHost(detail?.req ?? "", selected)} />
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="response" className="min-h-0 overflow-auto">
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center gap-2 p-5 text-xs text-muted-foreground">
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
加载报文…
|
||||
</div>
|
||||
) : (
|
||||
<HttpCodeBlock raw={detail?.resp ?? ""} />
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<AlertDialog
|
||||
open={deleteMode !== null}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setDeleteMode(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
@@ -533,9 +615,7 @@ export default function TrafficPage() {
|
||||
<AlertDialogDescription>
|
||||
{deleteMode === "selected" ? (
|
||||
<>
|
||||
将永久删除{" "}
|
||||
<span className="font-semibold tabular-nums">{selectedHosts.length}</span>{" "}
|
||||
个目标(
|
||||
将永久删除 <span className="font-semibold tabular-nums">{selectedHosts.length}</span> 个目标(
|
||||
<span className="font-mono">
|
||||
{selectedHosts.slice(0, 3).join("、")}
|
||||
{selectedHosts.length > 3 ? "…" : ""}
|
||||
@@ -544,8 +624,7 @@ export default function TrafficPage() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
将永久删除 host 包含{" "}
|
||||
<span className="font-mono font-semibold">{hostQ}</span>{" "}
|
||||
将永久删除 host 包含 <span className="font-mono font-semibold">{hostQ}</span>{" "}
|
||||
的所有流量记录(含请求/响应原文),此操作不可撤销。
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -24,17 +24,10 @@ import {
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/lib/api";
|
||||
@@ -172,19 +165,19 @@ function TaskTemplateManager({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="grid max-h-[90svh] max-w-4xl grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="border-b px-6 py-5">
|
||||
<DialogTitle>任务模板管理</DialogTitle>
|
||||
<DialogDescription>模板只保存描述与目标;修改不会影响已经创建的任务。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid min-h-0 overflow-y-auto md:grid-cols-[15rem_minmax(0,1fr)] md:overflow-hidden">
|
||||
<div className="flex min-h-0 flex-col border-b p-3 md:border-r md:border-b-0">
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="grid h-full w-full! max-w-none! grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:w-[48rem]! sm:max-w-[48rem]!">
|
||||
<SheetHeader className="border-b px-6 py-5">
|
||||
<SheetTitle>任务模板管理</SheetTitle>
|
||||
<SheetDescription>模板只保存描述与目标;修改不会影响已经创建的任务。</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="grid min-h-0 overflow-y-auto lg:grid-cols-[15rem_minmax(0,1fr)] lg:overflow-hidden">
|
||||
<div className="flex min-h-0 flex-col border-b p-3 lg:border-r lg:border-b-0">
|
||||
<Button type="button" variant="outline" className="w-full" onClick={startNew}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
新建模板
|
||||
</Button>
|
||||
<ScrollArea className="mt-2 max-h-44 md:max-h-[27rem]">
|
||||
<ScrollArea className="mt-2 max-h-44 lg:max-h-none lg:flex-1">
|
||||
<div className="flex flex-col gap-1 pr-2">
|
||||
{templates.length === 0 && (
|
||||
<p className="px-2 py-6 text-center text-muted-foreground text-sm">暂无模板</p>
|
||||
@@ -206,7 +199,7 @@ function TaskTemplateManager({
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<ScrollArea className="max-h-[58svh]">
|
||||
<ScrollArea className="min-h-0">
|
||||
<FieldGroup className="p-6">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="task-template-name">模板名称</FieldLabel>
|
||||
@@ -241,7 +234,7 @@ function TaskTemplateManager({
|
||||
</FieldGroup>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<DialogFooter className="border-t px-6 py-4">
|
||||
<SheetFooter className="border-t px-6 py-4 sm:flex-row sm:items-center">
|
||||
{selectedID != null && (
|
||||
<Button type="button" variant="destructive" className="sm:mr-auto" onClick={() => setDeleteOpen(true)}>
|
||||
<Trash2Icon data-icon="inline-start" />
|
||||
@@ -255,9 +248,9 @@ function TaskTemplateManager({
|
||||
{saving ? <Spinner data-icon="inline-start" /> : <SaveIcon data-icon="inline-start" />}
|
||||
{saveLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
@@ -303,6 +296,7 @@ export function TaskTemplateControls({
|
||||
}: TaskTemplateControlsProps) {
|
||||
const [templates, setTemplates] = React.useState<TaskTemplate[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [templateInputValue, setTemplateInputValue] = React.useState("");
|
||||
const [pendingTemplate, setPendingTemplate] = React.useState<TaskTemplate | null>(null);
|
||||
const [managerOpen, setManagerOpen] = React.useState(false);
|
||||
const [managerSeed, setManagerSeed] = React.useState<Pick<TemplateDraft, "description" | "goal"> | null>(null);
|
||||
@@ -323,25 +317,28 @@ export function TaskTemplateControls({
|
||||
void loadTemplates();
|
||||
}, [loadTemplates]);
|
||||
|
||||
const templateIDs = React.useMemo(() => templates.map((template) => String(template.id)), [templates]);
|
||||
const templatesByID = React.useMemo(
|
||||
() => new Map(templates.map((template) => [String(template.id), template])),
|
||||
[templates],
|
||||
const selectedTemplate = React.useMemo(
|
||||
() => templates.find((template) => template.id === selectedTemplateID) ?? null,
|
||||
[selectedTemplateID, templates],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
setTemplateInputValue(selectedTemplate?.name ?? "");
|
||||
}, [selectedTemplate]);
|
||||
|
||||
const applyTemplate = (template: TaskTemplate) => {
|
||||
onApply(template);
|
||||
onSelectedTemplateIDChange(template.id);
|
||||
setPendingTemplate(null);
|
||||
};
|
||||
|
||||
const chooseTemplate = (value: string | null) => {
|
||||
if (!value) {
|
||||
const chooseTemplate = (template: TaskTemplate | null) => {
|
||||
if (!template) {
|
||||
setTemplateInputValue("");
|
||||
onSelectedTemplateIDChange(null);
|
||||
return;
|
||||
}
|
||||
const template = templatesByID.get(value);
|
||||
if (!template) return;
|
||||
setTemplateInputValue(template.name);
|
||||
const hasContent = description.trim() !== "" || goal.trim() !== "";
|
||||
const changesContent = description !== template.description || goal !== template.goal;
|
||||
if (hasContent && changesContent) {
|
||||
@@ -391,9 +388,12 @@ export function TaskTemplateControls({
|
||||
</div>
|
||||
</div>
|
||||
<Combobox
|
||||
items={templateIDs}
|
||||
itemToStringValue={(id) => templatesByID.get(id)?.name ?? id}
|
||||
value={selectedTemplateID == null ? null : String(selectedTemplateID)}
|
||||
items={templates}
|
||||
itemToStringLabel={(template) => template.name}
|
||||
itemToStringValue={(template) => String(template.id)}
|
||||
inputValue={templateInputValue}
|
||||
onInputValueChange={(value) => setTemplateInputValue(value)}
|
||||
value={selectedTemplate}
|
||||
onValueChange={chooseTemplate}
|
||||
>
|
||||
<ComboboxInput
|
||||
@@ -406,27 +406,31 @@ export function TaskTemplateControls({
|
||||
<ComboboxContent portalContainer={portalContainer}>
|
||||
<ComboboxEmpty>没有匹配的模板</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(id) => {
|
||||
const template = templatesByID.get(id);
|
||||
return (
|
||||
<ComboboxItem key={id} value={id}>
|
||||
<LibraryIcon />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-sm">{template?.name ?? `模板 #${id}`}</p>
|
||||
{template?.description && (
|
||||
<p className="truncate text-muted-foreground text-xs">{template.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</ComboboxItem>
|
||||
);
|
||||
}}
|
||||
{(template) => (
|
||||
<ComboboxItem key={template.id} value={template}>
|
||||
<LibraryIcon />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-sm">{template.name}</p>
|
||||
{template.description && (
|
||||
<p className="truncate text-muted-foreground text-xs">{template.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
<FieldDescription>选择后会复制模板的描述和目标,不与模板保持关联。</FieldDescription>
|
||||
</Field>
|
||||
|
||||
<AlertDialog open={pendingTemplate != null} onOpenChange={(open) => !open && setPendingTemplate(null)}>
|
||||
<AlertDialog
|
||||
open={pendingTemplate != null}
|
||||
onOpenChange={(open) => {
|
||||
if (open) return;
|
||||
setPendingTemplate(null);
|
||||
setTemplateInputValue(selectedTemplate?.name ?? "");
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>使用模板「{pendingTemplate?.name}」?</AlertDialogTitle>
|
||||
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
AgentDetail,
|
||||
AgentTrigger,
|
||||
Asset,
|
||||
AssetNode,
|
||||
Audit,
|
||||
BatchControlItem,
|
||||
ChatAttachment,
|
||||
@@ -56,7 +55,6 @@ import type {
|
||||
SSTask,
|
||||
Stats,
|
||||
Task,
|
||||
TaskAssetView,
|
||||
TaskLLMResolutions,
|
||||
TaskNode,
|
||||
TaskScopeRow,
|
||||
@@ -230,11 +228,6 @@ export const api = {
|
||||
state: "paused" | "open" | "cancelled";
|
||||
deleted?: { intents: number; facts: number; findings: number; activities: number };
|
||||
}>(`/tasks/${taskId}/intents/${intentId}/control`, { action }),
|
||||
controlIntentsBatch: (taskId: string, intentIds: string[], action: "pause" | "resume") =>
|
||||
post<{ items: BatchControlItem[] }>(`/tasks/${taskId}/intents/control/batch`, {
|
||||
intent_ids: intentIds,
|
||||
action,
|
||||
}),
|
||||
taskLLMResolution: (id: string) => get<TaskLLMResolutions>(`/tasks/${id}/llm/resolution`),
|
||||
// 重跑一条没跑成功的意图(blocked/exhausted/stopped):置回 open,worker 会重新认领、从头再跑。
|
||||
rerunIntent: (taskId: string, intentId: string) =>
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import type { CompanyScopeKind, CompanyScopeRule } from "@/lib/types";
|
||||
|
||||
export const MAX_COMPANY_SCOPE_RULES = 256;
|
||||
export const MAX_COMPANY_SCOPE_VALUE_LENGTH = 1024;
|
||||
|
||||
export type CompanyScopeTextIssue = {
|
||||
line: number;
|
||||
rule?: CompanyScopeRule;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ParsedCompanyScopeText = {
|
||||
rules: CompanyScopeRule[];
|
||||
errors: Array<{ line: number; error: string }>;
|
||||
};
|
||||
|
||||
export const COMPANY_SCOPE_KINDS = new Set<CompanyScopeKind>(["domain", "ip", "cidr", "icp", "keyword"]);
|
||||
|
||||
export function isCompanyScopeKind(value: unknown): value is CompanyScopeKind {
|
||||
return typeof value === "string" && COMPANY_SCOPE_KINDS.has(value as CompanyScopeKind);
|
||||
}
|
||||
|
||||
function isIPv4(value: string): boolean {
|
||||
const parts = value.split(".");
|
||||
return (
|
||||
parts.length === 4 && parts.every((part) => (part === "0" || /^[1-9]\d{0,2}$/.test(part)) && Number(part) <= 255)
|
||||
);
|
||||
}
|
||||
|
||||
function isIPv6(value: string): boolean {
|
||||
if (!value.includes(":") || /[^0-9a-f:.]/i.test(value) || value.includes(":::")) return false;
|
||||
const halves = value.split("::");
|
||||
if (halves.length > 2) return false;
|
||||
const countSegments = (half: string): number | null => {
|
||||
if (!half) return 0;
|
||||
const segments = half.split(":");
|
||||
let count = 0;
|
||||
for (const [index, segment] of segments.entries()) {
|
||||
if (segment.includes(".")) {
|
||||
if (index !== segments.length - 1 || !isIPv4(segment)) return null;
|
||||
count += 2;
|
||||
} else {
|
||||
if (!/^[0-9a-f]{1,4}$/i.test(segment)) return null;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
};
|
||||
const left = countSegments(halves[0]);
|
||||
const right = countSegments(halves[1] ?? "");
|
||||
if (left === null || right === null) return false;
|
||||
return halves.length === 2 ? left + right < 8 : left === 8;
|
||||
}
|
||||
|
||||
function ipVersion(value: string): 4 | 6 | null {
|
||||
if (isIPv4(value)) return 4;
|
||||
if (isIPv6(value)) return 6;
|
||||
return null;
|
||||
}
|
||||
|
||||
function looksLikeIPAddress(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.includes("://") || /\s/.test(trimmed)) return false;
|
||||
if ((trimmed.match(/:/g) ?? []).length >= 2) {
|
||||
const parts = trimmed.split(":");
|
||||
let validSegments = 0;
|
||||
for (const part of parts) {
|
||||
if (!part) {
|
||||
if (validSegments > 0 || trimmed.startsWith("::")) return true;
|
||||
continue;
|
||||
}
|
||||
if (part.length > 4 || !/^[0-9a-f]+$/i.test(part)) return false;
|
||||
validSegments++;
|
||||
if (validSegments >= 2) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return trimmed.includes(".") && /^[0-9.]+$/.test(trimmed);
|
||||
}
|
||||
|
||||
function domainHostname(value: string): string | null {
|
||||
try {
|
||||
let candidate = value;
|
||||
if (value.startsWith("//")) candidate = `http:${value}`;
|
||||
else if (!value.includes("://")) candidate = `http://${value}`;
|
||||
const url = new URL(candidate);
|
||||
return (
|
||||
url.hostname
|
||||
.replace(/^\[|\]$/g, "")
|
||||
.replace(/\.$/, "")
|
||||
.toLowerCase() || null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function companyScopeRuleError(rule: CompanyScopeRule): string {
|
||||
const value = rule.value.trim();
|
||||
if (!value) return "请填写范围值";
|
||||
if (Array.from(value).length > MAX_COMPANY_SCOPE_VALUE_LENGTH) {
|
||||
return `最多 ${MAX_COMPANY_SCOPE_VALUE_LENGTH} 个字符`;
|
||||
}
|
||||
if (rule.kind === "domain") {
|
||||
if (/\s/.test(value)) return "请输入不含空格的有效域名或 URL";
|
||||
const hostname = domainHostname(value);
|
||||
if (!hostname || ipVersion(hostname) !== null) return "请输入有效域名或 URL";
|
||||
const labels = hostname.split(".");
|
||||
if (
|
||||
hostname.length > 253 ||
|
||||
labels.length < 2 ||
|
||||
labels.some(
|
||||
(label) =>
|
||||
!label || label.length > 63 || label.startsWith("-") || label.endsWith("-") || !/^[a-z0-9-]+$/i.test(label),
|
||||
)
|
||||
) {
|
||||
return "请输入有效域名或 URL";
|
||||
}
|
||||
}
|
||||
if (rule.kind === "ip" && ipVersion(value) === null) return "请输入有效 IP";
|
||||
if (rule.kind === "cidr") {
|
||||
const separator = value.lastIndexOf("/");
|
||||
if (separator <= 0) return "请输入 CIDR 网段";
|
||||
const address = value.slice(0, separator);
|
||||
const prefixText = value.slice(separator + 1);
|
||||
const version = ipVersion(address);
|
||||
if (version === null || !/^\d+$/.test(prefixText)) return "请输入 CIDR 网段";
|
||||
const prefix = Number(prefixText);
|
||||
if (version === 4 && (prefix < 16 || prefix > 32)) return "IPv4 网段前缀需为 /16 至 /32";
|
||||
if (version === 6 && (prefix < 32 || prefix > 128)) return "IPv6 网段前缀需为 /32 至 /128";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function classifyCompanyScopeLine(
|
||||
raw: string,
|
||||
line: number,
|
||||
preservedRule?: CompanyScopeRule,
|
||||
): CompanyScopeTextIssue {
|
||||
const value = raw.trim();
|
||||
if (Array.from(value).length > MAX_COMPANY_SCOPE_VALUE_LENGTH) {
|
||||
return { line, error: `最多 ${MAX_COMPANY_SCOPE_VALUE_LENGTH} 个字符` };
|
||||
}
|
||||
if (preservedRule) {
|
||||
const rule = { kind: preservedRule.kind, value };
|
||||
const error = companyScopeRuleError(rule);
|
||||
return error ? { line, error } : { line, rule };
|
||||
}
|
||||
|
||||
const separator = value.lastIndexOf("/");
|
||||
if (
|
||||
separator > 0 &&
|
||||
(ipVersion(value.slice(0, separator)) !== null || looksLikeIPAddress(value.slice(0, separator)))
|
||||
) {
|
||||
const rule: CompanyScopeRule = { kind: "cidr", value };
|
||||
const error = companyScopeRuleError(rule);
|
||||
return error ? { line, error } : { line, rule };
|
||||
}
|
||||
if (ipVersion(value) !== null) return { line, rule: { kind: "ip", value } };
|
||||
if (looksLikeIPAddress(value)) return { line, error: "请输入有效 IP" };
|
||||
|
||||
const looksLikeDomain = value.includes("://") || (!/\s/.test(value) && value.includes("."));
|
||||
if (looksLikeDomain) {
|
||||
const hostname = domainHostname(value);
|
||||
if (hostname && ipVersion(hostname) !== null) return { line, rule: { kind: "ip", value: hostname } };
|
||||
const rule: CompanyScopeRule = { kind: "domain", value };
|
||||
const error = companyScopeRuleError(rule);
|
||||
return error ? { line, error } : { line, rule };
|
||||
}
|
||||
if (/icp|备案/i.test(value)) return { line, rule: { kind: "icp", value } };
|
||||
return { line, rule: { kind: "keyword", value } };
|
||||
}
|
||||
|
||||
export function parseCompanyScopeText(
|
||||
value: string,
|
||||
options: { preservedRules?: CompanyScopeRule[]; maxRules?: number } = {},
|
||||
): ParsedCompanyScopeText {
|
||||
const nonEmpty = value
|
||||
.split(/\r?\n/)
|
||||
.map((raw, index) => ({ raw, line: index + 1 }))
|
||||
.filter(({ raw }) => raw.trim());
|
||||
const preserved = new Map<string, CompanyScopeRule[]>();
|
||||
for (const rule of options.preservedRules ?? []) {
|
||||
const key = rule.value.trim();
|
||||
preserved.set(key, [...(preserved.get(key) ?? []), rule]);
|
||||
}
|
||||
const issues = nonEmpty.map(({ raw, line }) => {
|
||||
const key = raw.trim();
|
||||
const queue = preserved.get(key);
|
||||
const preservedRule = queue?.shift();
|
||||
return classifyCompanyScopeLine(raw, line, preservedRule);
|
||||
});
|
||||
const maxRules = options.maxRules ?? MAX_COMPANY_SCOPE_RULES;
|
||||
if (nonEmpty.length > maxRules) {
|
||||
issues.push({
|
||||
line: nonEmpty[Math.max(0, maxRules)]?.line ?? 1,
|
||||
error: `最多允许 ${maxRules} 条范围,当前 ${nonEmpty.length} 条`,
|
||||
});
|
||||
}
|
||||
return {
|
||||
rules: issues.flatMap((item) => (item.rule ? [item.rule] : [])),
|
||||
errors: issues.flatMap((item) => (item.error ? [{ line: item.line, error: item.error }] : [])),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCompanyScopeValue(rule: CompanyScopeRule): string {
|
||||
const value = rule.value.trim();
|
||||
if (rule.kind === "domain") return domainHostname(value) ?? value.toLowerCase();
|
||||
if (rule.kind === "icp") return value.toLowerCase().replace(/\s/gu, "");
|
||||
if (rule.kind === "keyword") return value.toLowerCase().split(/\s+/u).filter(Boolean).join(" ");
|
||||
return value.toLowerCase();
|
||||
}
|
||||
+69
-27
@@ -2,6 +2,13 @@
|
||||
// 未命中的一律返回安全默认([] / {} / {ok:true}),保证任何页面都不崩。
|
||||
// 只在 NEXT_PUBLIC_MOCK=1 时经由 api.ts 的 http() 短路进入这里。
|
||||
|
||||
import {
|
||||
classifyCompanyScopeLine,
|
||||
companyScopeRuleError,
|
||||
isCompanyScopeKind,
|
||||
MAX_COMPANY_SCOPE_RULES,
|
||||
normalizeCompanyScopeValue,
|
||||
} from "../company-scope";
|
||||
import type {
|
||||
Asset,
|
||||
BatchControlItem,
|
||||
@@ -36,24 +43,53 @@ function mockAssetCounts(): Record<string, number> {
|
||||
}, {});
|
||||
}
|
||||
|
||||
function mockScopeRows(companyID: number, input: unknown): ScopeRow[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
function mockScopeRows(
|
||||
companyID: number,
|
||||
input: unknown,
|
||||
existing: ScopeRow[] = [],
|
||||
): { rows: ScopeRow[]; invalid: number; skipped: number } {
|
||||
if (!Array.isArray(input)) return { rows: [], invalid: 0, skipped: 0 };
|
||||
if (input.length > MAX_COMPANY_SCOPE_RULES) throw new Error(`企业范围最多支持 ${MAX_COMPANY_SCOPE_RULES} 条`);
|
||||
let nextID =
|
||||
mockCompanies.flatMap((company) => company.scope ?? []).reduce((max, row) => Math.max(max, row.id), 0) + 1;
|
||||
const rows: ScopeRow[] = [];
|
||||
for (const candidate of input) {
|
||||
if (!candidate || typeof candidate !== "object") continue;
|
||||
const rule = candidate as Partial<CompanyScopeRule>;
|
||||
const kind = rule.kind;
|
||||
const value = String(rule.value ?? "").trim();
|
||||
if (!kind || !value) continue;
|
||||
const row: ScopeRow = { id: nextID++, company_id: companyID, kind, raw: value };
|
||||
if (kind === "domain") row.domain = value.toLowerCase();
|
||||
else if (kind === "ip" || kind === "cidr") row.net = value;
|
||||
else row.value = value;
|
||||
let invalid = 0;
|
||||
let skipped = 0;
|
||||
const keys = new Set(
|
||||
existing.map((row) => `${row.kind}|${row.domain ?? row.net ?? row.value ?? row.raw.trim().toLowerCase()}`),
|
||||
);
|
||||
for (const [index, candidate] of input.entries()) {
|
||||
let rule: CompanyScopeRule | undefined;
|
||||
if (typeof candidate === "string") {
|
||||
rule = classifyCompanyScopeLine(candidate, index + 1).rule;
|
||||
} else if (candidate && typeof candidate === "object") {
|
||||
const item = candidate as { kind?: unknown; value?: unknown };
|
||||
const value = String(item.value ?? "").trim();
|
||||
if (item.kind === undefined || item.kind === "") rule = classifyCompanyScopeLine(value, index + 1).rule;
|
||||
else if (isCompanyScopeKind(item.kind)) rule = { kind: item.kind, value };
|
||||
}
|
||||
if (!rule || companyScopeRuleError(rule)) {
|
||||
invalid++;
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeCompanyScopeValue(rule);
|
||||
const row: ScopeRow = { id: nextID++, company_id: companyID, kind: rule.kind, raw: rule.value.trim() };
|
||||
if (rule.kind === "domain") row.domain = normalized;
|
||||
else if (rule.kind === "ip") row.net = `${normalized}/${normalized.includes(":") ? 128 : 32}`;
|
||||
else if (rule.kind === "cidr") row.net = normalized;
|
||||
else row.value = normalized;
|
||||
const key = `${row.kind}|${row.domain ?? row.net ?? row.value}`;
|
||||
if (keys.has(key)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
keys.add(key);
|
||||
rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
if (existing.length + rows.length > MAX_COMPANY_SCOPE_RULES) {
|
||||
throw new Error(`企业范围规则过多:每个企业最多 ${MAX_COMPANY_SCOPE_RULES} 条`);
|
||||
}
|
||||
return { rows, invalid, skipped };
|
||||
}
|
||||
|
||||
function mockProfileResolution(profileID: number | undefined, source: TaskLLMResolution["source"]): TaskLLMResolution {
|
||||
@@ -109,15 +145,17 @@ function bodyIDs(value: unknown): string[] {
|
||||
return ids;
|
||||
}
|
||||
|
||||
function controlMockIntent(id: string, action: "pause" | "resume"): BatchControlItem {
|
||||
type MockIntentControlResult = { ok: boolean; state?: string; error?: string };
|
||||
|
||||
function controlMockIntent(id: string, action: "pause" | "resume"): MockIntentControlResult {
|
||||
const intent = mockIntents.find((item) => item.id === id);
|
||||
if (!intent) return { id, ok: false, error: "意图不存在" };
|
||||
if (!intent) return { ok: false, error: "意图不存在" };
|
||||
const requiredState = action === "pause" ? "running" : "paused";
|
||||
if (intent.inherited || intent.state !== requiredState) {
|
||||
return { id, ok: false, state: intent.state, error: "Worker 状态已变化" };
|
||||
return { ok: false, state: intent.state, error: "Worker 状态已变化" };
|
||||
}
|
||||
intent.state = action === "pause" ? "paused" : "open";
|
||||
return { id, ok: true, state: intent.state };
|
||||
return { ok: true, state: intent.state };
|
||||
}
|
||||
|
||||
function controlMockTask(id: string, action: "pause" | "resume"): BatchControlItem {
|
||||
@@ -357,10 +395,6 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
const action = b.action === "resume" ? "resume" : "pause";
|
||||
return { items: bodyIDs(b.task_ids).map((id) => controlMockTask(id, action)) };
|
||||
}
|
||||
if (seg[0] === "tasks" && seg[2] === "intents" && seg[3] === "control" && seg[4] === "batch" && m === "POST") {
|
||||
const action = b.action === "resume" ? "resume" : "pause";
|
||||
return { items: bodyIDs(b.intent_ids).map((id) => controlMockIntent(id, action)) };
|
||||
}
|
||||
if (seg[0] === "tasks" && seg[2] === "intents" && seg[4] === "control" && m === "POST") {
|
||||
const id = seg[3];
|
||||
if (b.action === "cancel") {
|
||||
@@ -515,17 +549,25 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
throw new Error("企业已存在");
|
||||
}
|
||||
const id = mockCompanies.reduce((max, company) => Math.max(max, company.id), 0) + 1;
|
||||
const scope = mockScopeRows(id, b.scope);
|
||||
const company: Company = { id, name, asset_count: 0, scope };
|
||||
const scopeResult = mockScopeRows(id, b.scope);
|
||||
const company: Company = { id, name, asset_count: 0, scope: scopeResult.rows };
|
||||
mockCompanies.push(company);
|
||||
return { id, created: true, scope_added: scope.length, scope_invalid: 0 };
|
||||
return {
|
||||
id,
|
||||
created: true,
|
||||
scope_added: scopeResult.rows.length,
|
||||
scope_skipped: scopeResult.skipped,
|
||||
scope_invalid: scopeResult.invalid,
|
||||
};
|
||||
}
|
||||
if (seg[0] === "companies" && seg[2] === "scope" && m === "POST") {
|
||||
const company = mockCompanies.find((item) => item.id === Number(seg[1]));
|
||||
if (!company) throw new Error("企业不存在");
|
||||
const rows = mockScopeRows(company.id, b.scope);
|
||||
company.scope = b.reset === true ? rows : [...(company.scope ?? []), ...rows];
|
||||
return { added: rows.length, skipped: 0, invalid: 0 };
|
||||
const reset = b.reset === true;
|
||||
const scopeResult = mockScopeRows(company.id, b.scope, reset ? [] : (company.scope ?? []));
|
||||
if (reset && scopeResult.invalid > 0) throw new Error("企业范围包含无效规则,未覆盖原有范围");
|
||||
company.scope = reset ? scopeResult.rows : [...(company.scope ?? []), ...scopeResult.rows];
|
||||
return { added: scopeResult.rows.length, skipped: scopeResult.skipped, invalid: scopeResult.invalid };
|
||||
}
|
||||
if (seg[0] === "companies" && seg.length === 2 && m === "DELETE") {
|
||||
const id = Number(seg[1]);
|
||||
|
||||
@@ -555,7 +555,6 @@ export interface BatchControlItem {
|
||||
id: string;
|
||||
ok: boolean;
|
||||
status?: string;
|
||||
state?: string;
|
||||
queued?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user