Compare commits

...
Author SHA1 Message Date
ji ruo 21f68a6a60 feat: event-driven credential processing conversations 2026-10-30 09:18:31 +08:00
ji ruo 255e11aca6 feat(web): show agent source in approval records 2026-10-30 09:01:57 +08:00
ji ruo 6ad055d24f fix(web): enable repeater request undo 2026-10-30 02:12:36 +08:00
RuoJi6 571b6116e0 fix: place resource status on dashboard header
* fix: place resource status on dashboard header

* fix: show resource metrics as progress bars

* fix: preview unsupported resource layout

* fix: disable non-Linux resource preview
2026-10-29 10:59:32 -07:00
9 changed files with 167 additions and 86 deletions

No files matched your search

+1
View File
@@ -12,6 +12,7 @@ import (
// Missing host/setting means off. Rechecked on every write for running sessions. // Missing host/setting means off. Rechecked on every write for running sessions.
var IntelligenceEnabled func() bool var IntelligenceEnabled func() bool
var IntelligenceRecord func(context.Context, int64, int64, string, db.IntelligenceInput) (int64, error) var IntelligenceRecord func(context.Context, int64, int64, string, db.IntelligenceInput) (int64, error)
var CredentialRecorded func()
func intelligenceEnabled() bool { return IntelligenceEnabled != nil && IntelligenceEnabled() } func intelligenceEnabled() bool { return IntelligenceEnabled != nil && IntelligenceEnabled() }
func intelligenceSchema() map[string]any { func intelligenceSchema() map[string]any {
+17 -16
View File
@@ -39,22 +39,23 @@ type CredentialTraffic struct {
Accounts []int `json:"accounts"` Accounts []int `json:"accounts"`
} }
type CredentialAnalysis struct { type CredentialAnalysis struct {
ForceExtract bool `json:"force_extract,omitempty"` ConversationID int64 `json:"conversation_id,omitempty"`
TrafficNote string `json:"traffic_note,omitempty"` ForceExtract bool `json:"force_extract,omitempty"`
State string `json:"state"` TrafficNote string `json:"traffic_note,omitempty"`
Lease string `json:"lease,omitempty"` State string `json:"state"`
LeaseUntil time.Time `json:"lease_until,omitempty"` Lease string `json:"lease,omitempty"`
SourceHash string `json:"source_hash,omitempty"` LeaseUntil time.Time `json:"lease_until,omitempty"`
Method string `json:"method,omitempty"` SourceHash string `json:"source_hash,omitempty"`
Accounts []CredentialAccount `json:"accounts"` Method string `json:"method,omitempty"`
Traffic []CredentialTraffic `json:"traffic"` Accounts []CredentialAccount `json:"accounts"`
TrafficState string `json:"traffic_state"` Traffic []CredentialTraffic `json:"traffic"`
Note string `json:"note,omitempty"` TrafficState string `json:"traffic_state"`
Error string `json:"error,omitempty"` Note string `json:"note,omitempty"`
Model string `json:"model,omitempty"` Error string `json:"error,omitempty"`
InputTokens int `json:"input_tokens"` Model string `json:"model,omitempty"`
OutputTokens int `json:"output_tokens"` InputTokens int `json:"input_tokens"`
ProcessedAt *time.Time `json:"processed_at,omitempty"` OutputTokens int `json:"output_tokens"`
ProcessedAt *time.Time `json:"processed_at,omitempty"`
} }
func CredentialSourceHash(content string) string { func CredentialSourceHash(content string) string {
+7
View File
@@ -233,6 +233,13 @@ func wireTools(pg *db.DB, domainReg map[string]actool.CoreTool) {
if err != nil { if err != nil {
return 0, err return 0, err
} }
if item.Kind == "credential" {
// The durable database row is the source of truth; this wake-up only
// reduces latency. The recovery ticker handles missed notifications.
if agent.CredentialRecorded != nil {
agent.CredentialRecorded()
}
}
return item.ID, nil return item.ID, nil
} }
// Seed the built-in domain tools (first-insert only; DO NOTHING preserves edits). // Seed the built-in domain tools (first-insert only; DO NOTHING preserves edits).
+25 -1
View File
@@ -2,6 +2,7 @@ package server
import ( import (
"context" "context"
"encoding/json"
"io" "io"
"log" "log"
"net/http" "net/http"
@@ -41,13 +42,16 @@ func (s *Server) seedCredentialProcessor() {
_ = s.m.pg.SetBool(flag, true) _ = s.m.pg.SetBool(flag, true)
} }
func (s *Server) runCredentialProcessor() { func (s *Server) runCredentialProcessor() {
ticker := time.NewTicker(3 * time.Second) // Events wake the worker immediately. The slow ticker is only a recovery
// scan for rows created before a notification or after a process restart.
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop() defer ticker.Stop()
for { for {
select { select {
case <-s.ctx.Done(): case <-s.ctx.Done():
return return
case <-ticker.C: case <-ticker.C:
case <-s.credentialWake:
} }
if !s.m.pg.GetBool(db.SettingTaskIntelligence, false) || !s.m.pg.GetBool(db.SettingCredentialAgent, false) { if !s.m.pg.GetBool(db.SettingTaskIntelligence, false) || !s.m.pg.GetBool(db.SettingCredentialAgent, false) {
continue continue
@@ -93,6 +97,14 @@ func (s *Server) processCredentialJob(parent context.Context, item *db.Intellige
} }
}() }()
result := *item.Credentials result := *item.Credentials
conversationID := result.ConversationID
if conversationID == 0 {
if c, err := s.m.pg.CreateConversation(db.CredentialAgentKey, "凭据整理 · "+firstLine(item.Title, 42), nil); err == nil {
conversationID = c.ID
result.ConversationID = c.ID
_, _ = s.m.pg.AppendConvActivity(c.ID, db.Activity{Worker: db.CredentialAgentKey, Kind: "user", Summary: "收到凭据上报,开始整理", Detail: item.Content})
}
}
needsExtract := result.ForceExtract || result.SourceHash != db.CredentialSourceHash(item.Content) || result.Method == "" || (result.Error != "" && len(result.Accounts) == 0) needsExtract := result.ForceExtract || result.SourceHash != db.CredentialSourceHash(item.Content) || result.Method == "" || (result.Error != "" && len(result.Accounts) == 0)
result.State = "ready" result.State = "ready"
result.ForceExtract = false result.ForceExtract = false
@@ -185,8 +197,20 @@ func (s *Server) processCredentialJob(parent context.Context, item *db.Intellige
if err := s.m.pg.FinishCredentialJob(finish, item, result); err != nil { if err := s.m.pg.FinishCredentialJob(finish, item, result); err != nil {
log.Printf("[credentials] save record #%d: %v", item.ID, err) log.Printf("[credentials] save record #%d: %v", item.ID, err)
} }
if conversationID > 0 {
detail, _ := json.Marshal(result)
kind, summary := "result", "凭据整理完成"
isError := result.State == "error"
if isError {
kind, summary = "text", "凭据整理失败:"+result.Error
}
_, _ = s.m.pg.AppendConvActivity(conversationID, db.Activity{Worker: db.CredentialAgentKey, Kind: kind, IsError: isError, Summary: summary, Detail: string(detail), InputTokens: intPtr(result.InputTokens), OutputTokens: intPtr(result.OutputTokens)})
_ = s.m.pg.TouchConversation(conversationID)
}
} }
func intPtr(v int) *int { return &v }
var credentialURLs = regexp.MustCompile(`https?://[^\s<>"',;))]+`) var credentialURLs = regexp.MustCompile(`https?://[^\s<>"',;))]+`)
var credentialTrafficIDs = regexp.MustCompile(`\b[0-9]{8,}-[0-9]{3,}\b`) var credentialTrafficIDs = regexp.MustCompile(`\b[0-9]{8,}-[0-9]{3,}\b`)
+11 -4
View File
@@ -125,9 +125,10 @@ type Server struct {
// Cold task archives run through one persistent FIFO worker. The buffered wake // Cold task archives run through one persistent FIFO worker. The buffered wake
// channel coalesces enqueue bursts; the database remains the source of truth. // channel coalesces enqueue bursts; the database remains the source of truth.
archiveWake chan struct{} archiveWake chan struct{}
archiveWG sync.WaitGroup archiveWG sync.WaitGroup
side *sideQuestionState credentialWake chan struct{}
side *sideQuestionState
} }
// provEntry is a cached provider + its config for one LLM profile id. // provEntry is a cached provider + its config for one LLM profile id.
@@ -161,7 +162,7 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
triggerActive: map[string]int{}, triggerCfg: map[string]triggerBehavior{}, triggerActive: map[string]int{}, triggerCfg: map[string]triggerBehavior{},
profChatAgents: map[int64]*agent.ChatAgent{}, profChatAgents: map[int64]*agent.ChatAgent{},
provByProfile: map[int64]*provEntry{}, llmHealth: newLLMHealthRegistry(m.pg), provByProfile: map[int64]*provEntry{}, llmHealth: newLLMHealthRegistry(m.pg),
taskAgents: map[string]*taskAgentBundle{}, archiveWake: make(chan struct{}, 1)} taskAgents: map[string]*taskAgentBundle{}, archiveWake: make(chan struct{}, 1), credentialWake: make(chan struct{}, 1)}
s.initSideQuestions() s.initSideQuestions()
if m.pg != nil { if m.pg != nil {
if err := s.basicAuth.load(m.pg); err != nil { if err := s.basicAuth.load(m.pg); err != nil {
@@ -282,6 +283,12 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
go s.reconcileConcurrency() go s.reconcileConcurrency()
s.startTaskArchiveWorker() s.startTaskArchiveWorker()
if m.pg != nil { if m.pg != nil {
agent.CredentialRecorded = func() {
select {
case s.credentialWake <- struct{}{}:
default:
}
}
s.seedCredentialProcessor() s.seedCredentialProcessor()
go s.runCredentialProcessor() go s.runCredentialProcessor()
} }
@@ -6,11 +6,9 @@ import { usePathname } from "next/navigation";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { SidebarTrigger } from "@/components/ui/sidebar"; import { SidebarTrigger } from "@/components/ui/sidebar";
import { CpuIcon, HardDriveIcon, MemoryStickIcon } from "lucide-react";
import { useCurrentUser } from "@/hooks/use-current-user"; import { useCurrentUser } from "@/hooks/use-current-user";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { SystemMetrics } from "@/lib/types";
import { EgressIpNavbar, EgressIpProvider } from "./egress-ip-banner"; import { EgressIpNavbar, EgressIpProvider } from "./egress-ip-banner";
import { LiveClock } from "./live-clock"; import { LiveClock } from "./live-clock";
@@ -78,7 +76,6 @@ function MainContentInner({ children }: { children: ReactNode }) {
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<EgressIpNavbar /> <EgressIpNavbar />
<SystemResourceNavbar />
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<LiveClock /> <LiveClock />
@@ -98,42 +95,3 @@ function MainContentInner({ children }: { children: ReactNode }) {
</> </>
); );
} }
function SystemResourceNavbar() {
const [metrics, setMetrics] = useState<SystemMetrics | null>(null);
useEffect(() => {
let alive = true;
const load = async () => {
try {
const next = await api.systemMetrics();
if (alive) setMetrics(next);
} catch {
/* Keep the status bar quiet while the API is unavailable. */
}
};
void load();
const timer = setInterval(load, 5000);
return () => { alive = false; clearInterval(timer); };
}, []);
if (!metrics) return null;
if (!metrics.supported) {
return <div className="flex items-center justify-center text-xs text-muted-foreground" role="status">资源状态:暂不支持</div>;
}
const items = [
[CpuIcon, "CPU", metrics.cpu.percent],
[MemoryStickIcon, "内存", metrics.memory.percent],
[HardDriveIcon, "磁盘", metrics.data_disk.percent],
] as const;
return (
<div className="flex items-center justify-center gap-3 text-[10px] text-muted-foreground" role="status" aria-live="polite">
<span>容器资源</span>
{items.map(([Icon, label, value]) => (
<span key={label} className="inline-flex items-center gap-1 tabular-nums" title={`${label} ${value.toFixed(1)}%`}>
<Icon className="size-3" aria-hidden="true" /> {label} {value.toFixed(1)}%
</span>
))}
</div>
);
}
+52
View File
@@ -9,6 +9,9 @@ import {
ArrowUpRightIcon, ArrowUpRightIcon,
BugIcon, BugIcon,
ClockIcon, ClockIcon,
CpuIcon,
HardDriveIcon,
MemoryStickIcon,
NetworkIcon, NetworkIcon,
ShieldCheckIcon, ShieldCheckIcon,
TargetIcon, TargetIcon,
@@ -38,6 +41,7 @@ import type {
Tool, Tool,
TrafficExchange, TrafficExchange,
UsageStats, UsageStats,
SystemMetrics,
} from "@/lib/types"; } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -101,6 +105,35 @@ function statusBg(code: number): string {
return "bg-red-400"; return "bg-red-400";
} }
function SystemResourceStatus({ metrics }: { metrics: SystemMetrics | null }) {
if (!metrics) return null;
if (!metrics.supported) {
return <div className="ml-auto text-xs text-muted-foreground" role="status">资源状态:暂不支持</div>;
}
const items = [
[CpuIcon, "CPU", metrics.cpu.percent],
[MemoryStickIcon, "内存", metrics.memory.percent],
[HardDriveIcon, "磁盘", metrics.data_disk.percent],
] as const;
return (
<div className="ml-auto flex min-w-0 items-center justify-end gap-3 text-xs text-muted-foreground" role="status" aria-live="polite">
<span className="shrink-0 font-medium">容器资源</span>
{items.map(([Icon, label, value]) => (
<div key={label} className="w-24 min-w-0" title={`${label} ${value.toFixed(1)}%`}>
<div className="mb-1 flex items-center justify-between gap-1 text-[10px]">
<span className="inline-flex items-center gap-1"><Icon className="size-3" aria-hidden="true" />{label}</span>
<span className="tabular-nums">{value.toFixed(1)}%</span>
</div>
<Progress
value={value}
className="h-2 bg-muted [&_[data-slot=progress-indicator]]:bg-slate-500 dark:[&_[data-slot=progress-indicator]]:bg-slate-400"
/>
</div>
))}
</div>
);
}
// ── sub-components ──────────────────────────────────────────────────────────── // ── sub-components ────────────────────────────────────────────────────────────
function LiveDot({ className }: { className?: string }) { function LiveDot({ className }: { className?: string }) {
@@ -147,6 +180,22 @@ export default function DashboardPage() {
const [skills, setSkills] = React.useState<SkillItem[]>([]); const [skills, setSkills] = React.useState<SkillItem[]>([]);
const [tools, setTools] = React.useState<Tool[]>([]); const [tools, setTools] = React.useState<Tool[]>([]);
const [llmProfiles, setLLMProfiles] = React.useState<LLMProfile[]>([]); const [llmProfiles, setLLMProfiles] = React.useState<LLMProfile[]>([]);
const [systemMetrics, setSystemMetrics] = React.useState<SystemMetrics | null>(null);
React.useEffect(() => {
let alive = true;
const load = async () => {
try {
const next = await api.systemMetrics();
if (alive) setSystemMetrics(next);
} catch {
/* retry on the next interval */
}
};
void load();
const timer = setInterval(load, 5000);
return () => { alive = false; clearInterval(timer); };
}, []);
// The task list is the most expensive dashboard source. Poll it independently // The task list is the most expensive dashboard source. Poll it independently
// so a large history cannot hold back every other dashboard panel. // so a large history cannot hold back every other dashboard panel.
@@ -495,10 +544,13 @@ export default function DashboardPage() {
<div className="flex flex-1 flex-col gap-4 pb-6"> <div className="flex flex-1 flex-col gap-4 pb-6">
{/* ── Header ── */} {/* ── Header ── */}
<div> <div>
<div className="flex items-center gap-6">
<div> <div>
<h1 className="text-lg font-semibold tracking-tight">总览</h1> <h1 className="text-lg font-semibold tracking-tight">总览</h1>
<p className="text-xs text-muted-foreground">系统全局状态 · 实时刷新</p> <p className="text-xs text-muted-foreground">系统全局状态 · 实时刷新</p>
</div> </div>
<SystemResourceStatus metrics={systemMetrics} />
</div>
</div> </div>
{/* ── Row 1: 5 stat cards ── */} {/* ── Row 1: 5 stat cards ── */}
+25 -3
View File
@@ -65,6 +65,28 @@ function originLabel(row: InterceptApprovalRow) {
return "—"; return "—";
} }
// Background task runs carry a pool label such as "work#2 · #2770", while
// conversation-backed runs carry the agent key (for example "reporter").
// Present a stable, human-readable agent label in both cases.
function agentLabel(row: InterceptApprovalRow) {
const key = (row.conv_agent_key || "").trim().toLowerCase();
const labels: Record<string, string> = {
auto: "自动 Agent",
mainagent: "主 Agent",
planner: "规划 Agent",
worker: "执行 Worker",
reporter: "报告编写",
retester: "复测 Agent",
pentest: "渗透测试 Agent",
};
if (key && labels[key]) return labels[key];
const raw = (row.agent_name || "").trim();
if (/^work#\d+(?:\s|$)/i.test(raw)) return "执行 Worker";
const rawKey = raw.toLowerCase();
if (labels[rawKey]) return labels[rawKey];
return raw || "未知 Agent";
}
function ApprovalOrigin({ row, detail = false }: { row: InterceptApprovalRow; detail?: boolean }) { function ApprovalOrigin({ row, detail = false }: { row: InterceptApprovalRow; detail?: boolean }) {
const [locating, setLocating] = React.useState(false); const [locating, setLocating] = React.useState(false);
const label = detail && row.task_id ? `任务 ${row.task_id}` : originLabel(row); const label = detail && row.task_id ? `任务 ${row.task_id}` : originLabel(row);
@@ -454,7 +476,7 @@ export function ApprovalDetail({
<SideQuestionButton side={side} /> <SideQuestionButton side={side} />
</div> </div>
<CodeBlock <CodeBlock
label={`${current.agent_name || current.conv_agent_key || "Agent"} · 工具请求`} label={`${agentLabel(current)} · 工具请求`}
text={JSON.stringify(row.tool_input ?? {}, null, 2)} text={JSON.stringify(row.tool_input ?? {}, null, 2)}
/> />
{command ? ( {command ? (
@@ -726,8 +748,8 @@ function ApprovalTable({
<span className="truncate" title={originLabel(row)}> <span className="truncate" title={originLabel(row)}>
<ApprovalOrigin row={row} /> <ApprovalOrigin row={row} />
</span> </span>
<span className="truncate text-muted-foreground text-xs"> <span className="truncate text-muted-foreground text-xs" title={agentLabel(row)}>
{row.agent_name || row.conv_agent_key} Agent:{agentLabel(row)}
</span> </span>
</div> </div>
</TableCell> </TableCell>
+29 -20
View File
@@ -2,14 +2,8 @@
import * as React from "react"; import * as React from "react";
import { EditorState } from "@codemirror/state"; import { EditorState } from "@codemirror/state";
import { import { drawSelection, EditorView, highlightActiveLine, keymap, lineNumbers } from "@codemirror/view";
drawSelection, import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
EditorView,
highlightActiveLine,
keymap,
lineNumbers,
} from "@codemirror/view";
import { defaultKeymap, indentWithTab } from "@codemirror/commands";
import { defaultHighlightStyle, HighlightStyle, StreamLanguage, syntaxHighlighting } from "@codemirror/language"; import { defaultHighlightStyle, HighlightStyle, StreamLanguage, syntaxHighlighting } from "@codemirror/language";
import { tags } from "@lezer/highlight"; import { tags } from "@lezer/highlight";
@@ -37,17 +31,31 @@ const httpLanguage = StreamLanguage.define({
}, },
}); });
const packetTheme = EditorView.theme({ const packetTheme = EditorView.theme(
"&": { height: "100%", backgroundColor: "transparent", color: "var(--foreground)" }, {
".cm-scroller": { overflow: "auto", fontFamily: "var(--font-mono)", fontSize: "0.75rem", fontWeight: "500", lineHeight: "1.65rem" }, "&": { height: "100%", backgroundColor: "transparent", color: "var(--foreground)" },
".cm-content": { minHeight: "100%", padding: "0.5rem 0.5rem" }, ".cm-scroller": {
".cm-gutters": { backgroundColor: "transparent", color: "var(--muted-foreground)", border: "0", borderRight: "1px solid var(--border)" }, overflow: "auto",
".cm-lineNumbers .cm-gutterElement": { minWidth: "2rem", padding: "0 0.5rem" }, fontFamily: "var(--font-mono)",
".cm-activeLine": { backgroundColor: "transparent" }, fontSize: "0.75rem",
".cm-activeLineGutter": { backgroundColor: "transparent" }, fontWeight: "500",
".cm-selectionBackground, ::selection": { backgroundColor: "#c9d2e3" }, lineHeight: "1.65rem",
".cm-cursor": { borderLeftColor: "#222" }, },
}, { dark: false }); ".cm-content": { minHeight: "100%", padding: "0.5rem 0.5rem" },
".cm-gutters": {
backgroundColor: "transparent",
color: "var(--muted-foreground)",
border: "0",
borderRight: "1px solid var(--border)",
},
".cm-lineNumbers .cm-gutterElement": { minWidth: "2rem", padding: "0 0.5rem" },
".cm-activeLine": { backgroundColor: "transparent" },
".cm-activeLineGutter": { backgroundColor: "transparent" },
".cm-selectionBackground, ::selection": { backgroundColor: "#c9d2e3" },
".cm-cursor": { borderLeftColor: "#222" },
},
{ dark: false },
);
export function PacketCodeEditor({ export function PacketCodeEditor({
value, value,
@@ -76,7 +84,8 @@ export function PacketCodeEditor({
lineNumbers(), lineNumbers(),
drawSelection(), drawSelection(),
highlightActiveLine(), highlightActiveLine(),
keymap.of([...defaultKeymap, indentWithTab]), history(),
keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
httpLanguage, httpLanguage,
syntaxHighlighting(defaultHighlightStyle), syntaxHighlighting(defaultHighlightStyle),
syntaxHighlighting(packetHighlight), syntaxHighlighting(packetHighlight),