Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions webui/src/features/nodes/NodesPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createColumnHelper } from "@tanstack/react-table";
import { AlertTriangle, Eraser, Globe, RefreshCw, Sparkles, X, Zap } from "lucide-react";
import { AlertTriangle, Eraser, Globe, Layers, RefreshCw, Sparkles, X, Zap } from "lucide-react";
import { useEffect, useMemo, useState, type CSSProperties } from "react";
import { useLocation } from "react-router-dom";
import { Badge } from "../../components/ui/Badge";
Expand All @@ -15,7 +15,7 @@ import { useToast } from "../../hooks/useToast";
import { useI18n } from "../../i18n";
import { formatApiErrorMessage } from "../../lib/error-message";
import { formatDateTime, formatRelativeTime } from "../../lib/time";
import { listPlatforms } from "../platforms/api";
import { createPlatform, listPlatforms } from "../platforms/api";
import type { Platform } from "../platforms/types";
import { listSubscriptions } from "../subscriptions/api";
import { getNode, listNodes, probeEgress, probeLatency } from "./api";
Expand Down Expand Up @@ -187,6 +187,14 @@ function firstTag(node: { display_tag?: string; tags: { tag: string }[] }): stri
return node.tags[0].tag;
}

function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

function sanitizePlatformName(tag: string): string {
return "Node-" + tag.slice(0, 20).replace(/[.:|/\\@?#%~\s]+/g, "-").replace(/-+$/, "");
}

function hasReferenceLatency(node: NodeSummary): node is NodeSummary & { reference_latency_ms: number } {
return typeof node.reference_latency_ms === "number";
}
Expand Down Expand Up @@ -416,6 +424,17 @@ export function NodesPage() {
await probeLatencyMutation.mutateAsync(hash);
};

const createDirectPlatformMutation = useMutation({
mutationFn: createPlatform,
onSuccess: async (created) => {
await queryClient.invalidateQueries({ queryKey: ["platforms"] });
showToast("success", t("已创建专属平台: {{name}}", { name: created.name }));
},
onError: (error) => {
showToast("error", formatApiErrorMessage(error, t));
},
});

const handleFilterChange = (key: keyof NodeFilterDraft, value: string) => {
setDraftFilters((prev) => {
const next = { ...prev, [key]: value };
Expand Down Expand Up @@ -586,6 +605,22 @@ export function NodesPage() {
>
<Zap size={14} />
</Button>
<Button
size="sm"
variant="ghost"
title={t("创建专属平台路由")}
onClick={() => {
const tag = firstTag(node);
createDirectPlatformMutation.mutate({
name: sanitizePlatformName(tag),
regex_filters: [`^${escapeRegex(tag)}$`],
allocation_policy: "BALANCED",
});
}}
disabled={createDirectPlatformMutation.isPending}
>
<Layers size={14} />
</Button>
</div>
);
},
Expand Down
78 changes: 73 additions & 5 deletions webui/src/features/platforms/PlatformPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Info, Plus, RefreshCw, Search, Sparkles } from "lucide-react";
import { AlertTriangle, Info, Link, Plus, RefreshCw, Search, Sparkles } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
Expand Down Expand Up @@ -38,13 +38,34 @@ const ZERO_UUID = "00000000-0000-0000-0000-000000000000";
const EMPTY_PLATFORMS: Platform[] = [];
const PAGE_SIZE_OPTIONS = [12, 24, 48, 96] as const;

function copyToClipboard(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
return navigator.clipboard.writeText(text);
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
return Promise.resolve();
}

export function PlatformPage() {
const { t } = useI18n();
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState<number>(24);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [proxyHost, setProxyHost] = useState(() =>
localStorage.getItem("resin.webui.proxy_host") || `${window.location.hostname}:${window.location.port || "2260"}`
);
const [proxyToken, setProxyToken] = useState(() =>
localStorage.getItem("resin.webui.proxy_token") ?? ""
);
const { toasts, showToast, dismissToast } = useToast();

const queryClient = useQueryClient();
Expand Down Expand Up @@ -120,7 +141,28 @@ export function PlatformPage() {
<h3>{t("平台列表")}</h3>
<p>{t("共 {{count}} 个平台", { count: totalPlatforms })}</p>
</div>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", flexWrap: "wrap" }}>
<div style={{ display: "flex", gap: "0.35rem", alignItems: "center" }}>
<Input
placeholder="Host:Port"
value={proxyHost}
onChange={(e) => {
setProxyHost(e.target.value);
localStorage.setItem("resin.webui.proxy_host", e.target.value);
}}
style={{ maxWidth: 170, padding: "6px 10px", borderRadius: 8, fontSize: "0.85rem" }}
/>
<Input
placeholder="Proxy Token"
type="password"
value={proxyToken}
onChange={(e) => {
setProxyToken(e.target.value);
localStorage.setItem("resin.webui.proxy_token", e.target.value);
}}
style={{ maxWidth: 130, padding: "6px 10px", borderRadius: 8, fontSize: "0.85rem" }}
/>
</div>
<label className="search-box" htmlFor="platform-search" style={{ maxWidth: 200, margin: 0, gap: 6 }}>
<Search size={16} />
<Input
Expand Down Expand Up @@ -187,9 +229,35 @@ export function PlatformPage() {
>
<div className="platform-tile-head">
<p>{platform.name}</p>
<Badge variant={platform.id === ZERO_UUID ? "warning" : "success"}>
{platform.id === ZERO_UUID ? t("内置平台") : t("自定义平台")}
</Badge>
<span style={{ display: "flex", alignItems: "center", gap: "0.25rem" }}>
<Badge variant={platform.id === ZERO_UUID ? "warning" : "success"}>
{platform.id === ZERO_UUID ? t("内置平台") : t("自定义平台")}
</Badge>
<span
role="button"
tabIndex={0}
title={t("复制 HTTP 代理地址")}
style={{ cursor: "pointer", padding: "2px 4px", borderRadius: 4, display: "inline-flex", alignItems: "center" }}
onClick={(e) => {
e.stopPropagation();
const tokenPart = proxyToken || "";
const url = `http://${platform.name}:${tokenPart}@${proxyHost}`;
void copyToClipboard(url);
showToast("success", t("已复制: {{url}}", { url }));
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
const tokenPart = proxyToken || "";
const url = `http://${platform.name}:${tokenPart}@${proxyHost}`;
void copyToClipboard(url);
showToast("success", t("已复制: {{url}}", { url }));
}
}}
>
<Link size={14} />
</span>
</span>
</div>
<div className="platform-tile-facts">
<span className="platform-fact">
Expand Down
4 changes: 4 additions & 0 deletions webui/src/i18n/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,10 @@ const EXACT_ZH_TO_EN: Record<string, string> = {
"总请求": "Total requests",
"最近错误:{{message}}": "Recent error: {{message}}",
"配置已更新({{count}} 项变更)": "Config updated ({{count}} changes)",
"复制 HTTP 代理地址": "Copy HTTP Proxy URL",
"已复制: {{url}}": "Copied: {{url}}",
"创建专属平台路由": "Create Dedicated Platform Route",
"已创建专属平台: {{name}}": "Dedicated platform created: {{name}}",
};

export function translateDocumentTitle(locale: AppLocale): string {
Expand Down