diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/findings-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/findings-tab.tsx
index 07d9449..4a0dc59 100644
--- a/web/src/app/(main)/function/tasks/detail/_tabs/findings-tab.tsx
+++ b/web/src/app/(main)/function/tasks/detail/_tabs/findings-tab.tsx
@@ -1,12 +1,15 @@
"use client";
import * as React from "react";
+
import Link from "next/link";
-import { ArrowUpRightIcon, ChevronRightIcon } from "lucide-react";
-import { cn } from "@/lib/utils";
-import { Card, CardContent } from "@/components/ui/card";
-import { Badge } from "@/components/ui/badge";
+
+import { ArrowDownIcon, ArrowUpIcon, ArrowUpRightIcon, ChevronRightIcon } from "lucide-react";
+import { toast } from "sonner";
+
import { StatusBadge } from "@/components/status-badge";
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent } from "@/components/ui/card";
import {
Select,
SelectContent,
@@ -15,9 +18,21 @@ import {
SelectTrigger,
} from "@/components/ui/select";
import { api } from "@/lib/api";
-import { toast } from "sonner";
+import { useStoredSortPreference } from "@/lib/sort-preference";
import { statusMeta } from "@/lib/status";
import type { Finding, FindingStatus } from "@/lib/types";
+import { cn } from "@/lib/utils";
+
+type FindingSortField = "time";
+
+const FINDING_SORT_FIELDS: readonly FindingSortField[] = ["time"];
+const FINDING_SORT_PREFERENCE_KEY = "artex_task_findings_sort";
+
+function findingLabel(finding: Finding): string {
+ if (finding.name?.trim()) return finding.name;
+ if (finding.vulnclass?.trim()) return finding.vulnclass;
+ return "未分类";
+}
const FINDING_STATUSES: FindingStatus[] = [
"pending",
@@ -57,9 +72,7 @@ function Row({
-
- {f.name || f.vulnclass || "未分类"}
-
+
{findingLabel(f)}
{f.inherited && f.source_task_id && (
来源 #{f.source_task_id} · 只读
@@ -147,6 +160,12 @@ function Row({
export function FindingsTab({ taskId }: { taskId: string }) {
const [findings, setFindings] = React.useState([]);
+ const [sortPreference, setSortPreference] = useStoredSortPreference(
+ FINDING_SORT_PREFERENCE_KEY,
+ FINDING_SORT_FIELDS,
+ "time",
+ "desc",
+ );
React.useEffect(() => {
let active = true;
@@ -156,7 +175,9 @@ export function FindingsTab({ taskId }: { taskId: string }) {
.then((fs) => {
if (active) setFindings(fs);
})
- .catch(() => {});
+ .catch(() => {
+ // Keep the last successful snapshot during transient refresh failures.
+ });
};
load();
const t = setInterval(load, 3000);
@@ -189,13 +210,35 @@ export function FindingsTab({ taskId }: { taskId: string }) {
const items = findings
.filter((f) => f.task_id === taskId || f.inherited)
.sort((a, b) => {
- const order = { critical: 0, high: 1, medium: 2, low: 3 };
- return order[a.severity] - order[b.severity];
+ const compared = Date.parse(a.ts) - Date.parse(b.ts);
+ if (compared !== 0) return sortPreference.direction === "asc" ? compared : -compared;
+ return b.id.localeCompare(a.id, undefined, { numeric: true });
});
return (
+
+
漏洞
+
+
{items.map((f) => (
))}
diff --git a/web/src/app/(main)/function/tasks/page.tsx b/web/src/app/(main)/function/tasks/page.tsx
index 86f349a..f7050e2 100644
--- a/web/src/app/(main)/function/tasks/page.tsx
+++ b/web/src/app/(main)/function/tasks/page.tsx
@@ -112,6 +112,7 @@ import { Switch } from "@/components/ui/switch";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
+import { type SortDirection, useStoredSortPreference } from "@/lib/sort-preference";
import type {
ChatAttachment,
Company,
@@ -242,7 +243,9 @@ function taskControlAction(status: TaskStatus): "pause" | "resume" | null {
}
type TaskSortField = "id" | "created" | "duration" | "status";
-type SortDirection = "asc" | "desc";
+
+const TASK_SORT_FIELDS: readonly TaskSortField[] = ["id", "created", "duration", "status"];
+const TASK_SORT_PREFERENCE_KEY = "artex_task_list_sort";
const TASK_STATUS_RANK = new Map(STATUS_OPTIONS.map((option, index) => [option.value, index]));
@@ -292,8 +295,13 @@ export default function TasksPage() {
const [query, setQuery] = React.useState("");
const [statusFilter, setStatusFilter] = React.useState("all");
const [categoryFilter, setCategoryFilter] = React.useState("all");
- const [sortField, setSortField] = React.useState("id");
- const [sortDirection, setSortDirection] = React.useState("desc");
+ const [sortPreference, setSortPreference] = useStoredSortPreference(
+ TASK_SORT_PREFERENCE_KEY,
+ TASK_SORT_FIELDS,
+ "id",
+ "desc",
+ );
+ const { field: sortField, direction: sortDirection } = sortPreference;
const [page, setPage] = React.useState(1);
const [pageSize, setPageSize] = React.useState(20);
const [nowSec, setNowSec] = React.useState(() => Math.floor(Date.now() / 1000));
@@ -329,14 +337,12 @@ export default function TasksPage() {
const sortTasksBy = React.useCallback(
(field: TaskSortField) => {
- if (sortField === field) {
- setSortDirection((current) => (current === "asc" ? "desc" : "asc"));
- return;
- }
- setSortField(field);
- setSortDirection("desc");
+ setSortPreference((current) => {
+ if (current.field !== field) return { field, direction: "desc" };
+ return { field, direction: current.direction === "asc" ? "desc" : "asc" };
+ });
},
- [sortField],
+ [setSortPreference],
);
// reset to page 1 whenever filters or ordering change
@@ -1066,13 +1072,7 @@ const TaskRow = React.memo(function TaskRow({
-
- {task.name?.trim() ? task.name :
未命名}
-
+
{task.active &&
}
{taskIsPinned(task) &&
}
@@ -1139,7 +1139,7 @@ const TaskRow = React.memo(function TaskRow({
-
+
@@ -1147,117 +1147,141 @@ const TaskRow = React.memo(function TaskRow({
);
});
+function TaskNameEditor({
+ task,
+ onRename,
+}: {
+ task: Task;
+ onRename: (task: Task, name: string) => Promise
;
+}) {
+ const [editing, setEditing] = React.useState(false);
+ const [name, setName] = React.useState(task.name ?? "");
+ const [saving, setSaving] = React.useState(false);
+ const inputRef = React.useRef(null);
+ const cancelOnBlurRef = React.useRef(false);
+
+ React.useEffect(() => {
+ if (!editing) setName(task.name ?? "");
+ }, [editing, task.name]);
+
+ async function finishEditing() {
+ if (cancelOnBlurRef.current) {
+ cancelOnBlurRef.current = false;
+ setName(task.name ?? "");
+ setEditing(false);
+ return;
+ }
+
+ const next = name.trim();
+ if (!next || next === task.name?.trim()) {
+ setName(task.name ?? "");
+ setEditing(false);
+ return;
+ }
+
+ setSaving(true);
+ try {
+ await onRename(task, next);
+ setEditing(false);
+ } catch {
+ requestAnimationFrame(() => inputRef.current?.focus());
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ if (editing) {
+ return (
+
+ event.currentTarget.select()}
+ onChange={(event) => setName(event.target.value)}
+ onBlur={() => void finishEditing()}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ event.currentTarget.blur();
+ } else if (event.key === "Escape") {
+ event.preventDefault();
+ cancelOnBlurRef.current = true;
+ event.currentTarget.blur();
+ }
+ }}
+ />
+ {saving && }
+
+ );
+ }
+
+ return (
+
+
+ {task.name?.trim() ? task.name :
未命名}
+
+
+
+ );
+}
+
function taskPinIcon(pinning: boolean, pinned: boolean) {
if (pinning) return ;
if (pinned) return ;
return ;
}
-function TaskMetadataActions({
+function TaskPinAction({
task,
- onRename,
onTogglePinned,
}: {
task: Task;
- onRename: (task: Task, name: string) => Promise;
onTogglePinned: (task: Task) => Promise;
}) {
- const [renameOpen, setRenameOpen] = React.useState(false);
- const [name, setName] = React.useState(task.name ?? "");
- const [saving, setSaving] = React.useState(false);
const [pinning, setPinning] = React.useState(false);
const pinned = taskIsPinned(task);
- React.useEffect(() => {
- if (renameOpen) setName(task.name ?? "");
- }, [renameOpen, task.name]);
-
- async function saveName() {
- const next = name.trim();
- if (!next) return;
- if (next === task.name?.trim()) {
- setRenameOpen(false);
- return;
- }
- setSaving(true);
- try {
- await onRename(task, next);
- setRenameOpen(false);
- } catch {
- // The parent already reports the API error; keep the dialog open for retry.
- } finally {
- setSaving(false);
- }
- }
-
return (
- <>
-
-
-
- >
+
);
}
diff --git a/web/src/app/(main)/function/traffic/page.tsx b/web/src/app/(main)/function/traffic/page.tsx
index 9ccf7d0..b7623ff 100644
--- a/web/src/app/(main)/function/traffic/page.tsx
+++ b/web/src/app/(main)/function/traffic/page.tsx
@@ -3,14 +3,18 @@
import * as React from "react";
import {
+ CheckIcon,
ChevronLeftIcon,
ChevronRightIcon,
+ CopyIcon,
ListChecksIcon,
Loader2Icon,
RadioTowerIcon,
SearchIcon,
Trash2Icon,
+ WrapTextIcon,
} from "lucide-react";
+import { toast } from "sonner";
import {
AlertDialog,
@@ -32,6 +36,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { api } from "@/lib/api";
import type { TrafficDetail, TrafficExchange, TrafficHost, TrafficResp } from "@/lib/types";
import { cn } from "@/lib/utils";
@@ -163,37 +168,122 @@ function MarkupBody({ body }: { body: string }) {
return parts;
}
-function HighlightedBody({ body }: { body: string }) {
+type BodyFormat = "json" | "markup" | "plain";
+
+function detectBodyFormat(body: string): BodyFormat {
const trimmed = body.trimStart();
- if (trimmed.startsWith("{") || trimmed.startsWith("[")) return ;
- if (trimmed.startsWith("<")) return ;
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) return "json";
+ if (trimmed.startsWith("<")) return "markup";
+ return "plain";
+}
+
+function HighlightedBody({ body, format }: { body: string; format: BodyFormat }) {
+ if (format === "json") return ;
+ if (format === "markup") return ;
return body;
}
function HttpCodeBlock({ raw }: { raw: string }) {
+ const [wrapLines, setWrapLines] = React.useState(true);
+ const [copied, setCopied] = React.useState(false);
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") : "";
+ const bodyFormat = detectBodyFormat(body);
+
+ React.useEffect(() => {
+ if (!copied) return;
+ const timer = window.setTimeout(() => setCopied(false), 1500);
+ return () => window.clearTimeout(timer);
+ }, [copied]);
+
+ const copyPacket = async () => {
+ try {
+ await navigator.clipboard.writeText(value);
+ setCopied(true);
+ } catch {
+ toast.error("复制失败,请使用 Ctrl/Cmd+A 后复制");
+ }
+ };
+
+ const renderLine = (line: string, index: number) => {
+ if (index === 0) return ;
+ if (separator < 0 || index < separator) return ;
+ if (index === separator) return null;
+ return ;
+ };
return (
-
-
- {headerLines.map((line, index) => (
-
- {index === 0 ? : }
- {index < headerLines.length - 1 ? "\n" : null}
-
+
+
+
+
+
+
+ {wrapLines ? "关闭自动换行" : "开启自动换行"}
+
+
+
+
+
+ {copied ? "已复制" : "复制报文"}
+
+
+ {/* biome-ignore lint/a11y/useSemanticElements: textarea cannot preserve line numbers and syntax-highlighting markup. */}
+
{
+ if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "a") return;
+ event.preventDefault();
+ const selection = window.getSelection();
+ if (!selection) return;
+ const range = document.createRange();
+ range.selectNodeContents(event.currentTarget);
+ selection.removeAllRanges();
+ selection.addRange(range);
+ }}
+ >
+ {lines.map((line, index) => (
+
+
+ {renderLine(line, index)}
+
+
))}
- {separator >= 0 && (
- <>
- {"\n\n"}
-
- >
- )}
-
-
+
+
);
}
diff --git a/web/src/lib/sort-preference.ts b/web/src/lib/sort-preference.ts
new file mode 100644
index 0000000..20342f4
--- /dev/null
+++ b/web/src/lib/sort-preference.ts
@@ -0,0 +1,47 @@
+"use client";
+
+import * as React from "react";
+
+import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client";
+
+export type SortDirection = "asc" | "desc";
+
+export interface SortPreference {
+ field: Field;
+ direction: SortDirection;
+}
+
+export function useStoredSortPreference(
+ key: string,
+ fields: readonly Field[],
+ defaultField: Field,
+ defaultDirection: SortDirection,
+) {
+ const [preference, setPreference] = React.useState>({
+ field: defaultField,
+ direction: defaultDirection,
+ });
+ const [hydrated, setHydrated] = React.useState(false);
+
+ React.useEffect(() => {
+ const raw = getLocalStorageValue(key);
+ if (raw) {
+ try {
+ const parsed = JSON.parse(raw) as Partial>;
+ const field = fields.find((candidate) => candidate === parsed.field);
+ const direction = parsed.direction === "asc" || parsed.direction === "desc" ? parsed.direction : null;
+ if (field && direction) setPreference({ field, direction });
+ } catch {
+ // Ignore malformed or legacy preferences and retain the current default.
+ }
+ }
+ setHydrated(true);
+ }, [fields, key]);
+
+ React.useEffect(() => {
+ if (!hydrated) return;
+ setLocalStorageValue(key, JSON.stringify(preference));
+ }, [hydrated, key, preference]);
+
+ return [preference, setPreference] as const;
+}