优化ui
This commit is contained in:
parent
2e3eb4b8f8
commit
916a4f5ffc
@ -6,7 +6,6 @@ import { usePathname } from "next/navigation";
|
|||||||
type NavItem = {
|
type NavItem = {
|
||||||
href: string;
|
href: string;
|
||||||
label: string;
|
label: string;
|
||||||
meta: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AdminNav({ items }: { items: NavItem[] }) {
|
export function AdminNav({ items }: { items: NavItem[] }) {
|
||||||
@ -24,7 +23,6 @@ export function AdminNav({ items }: { items: NavItem[] }) {
|
|||||||
className={`nav-link ${active ? "is-active" : ""}`}
|
className={`nav-link ${active ? "is-active" : ""}`}
|
||||||
>
|
>
|
||||||
<span className="nav-link__label">{item.label}</span>
|
<span className="nav-link__label">{item.label}</span>
|
||||||
<span className="nav-link__meta">{item.meta}</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -1,15 +1,15 @@
|
|||||||
|
import { formatLocalDateTime } from "@/lib/datetime";
|
||||||
import { formatBytes, listAudits } from "@/lib/store";
|
import { formatBytes, listAudits } from "@/lib/store";
|
||||||
|
|
||||||
export default async function AuditsPage() {
|
export default async function AuditsPage() {
|
||||||
const audits = await listAudits();
|
const audits = await listAudits();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="page-stack">
|
<main className="page-stack management-page">
|
||||||
<section className="hero-panel hero-panel--compact">
|
<section className="hero-panel hero-panel--compact">
|
||||||
<div className="hero-copy">
|
<div className="hero-copy">
|
||||||
<span className="eyebrow">AUDIT</span>
|
<span className="eyebrow">审计</span>
|
||||||
<h2>鉴权审计</h2>
|
<h2>鉴权审计</h2>
|
||||||
<p className="page-lede">记录最近的 HTTP 鉴权回调结果、失败原因和请求来源。</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="hero-aside">
|
<div className="hero-aside">
|
||||||
<div className="stat-tile">
|
<div className="stat-tile">
|
||||||
@ -19,29 +19,34 @@ export default async function AuditsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="panel-card">
|
<section className="panel-card dense-panel">
|
||||||
<div className="stack-md">
|
<div className="entity-table entity-table--audits">
|
||||||
|
<div className="entity-table__head audit-row-grid">
|
||||||
|
<span>结果</span>
|
||||||
|
<span>节点 / 用户</span>
|
||||||
|
<span>来源</span>
|
||||||
|
<span>请求速率</span>
|
||||||
|
<span>原因</span>
|
||||||
|
<span>时间</span>
|
||||||
|
</div>
|
||||||
{audits.map((audit) => (
|
{audits.map((audit) => (
|
||||||
<div key={audit.id} className="list-row list-row--card">
|
<div key={audit.id} className="entity-table__summary audit-row-grid">
|
||||||
<div>
|
<span>
|
||||||
<div className="badge-row">
|
|
||||||
<span className={`status-pill ${audit.ok ? "status-pill--ok" : "status-pill--danger"}`}>
|
<span className={`status-pill ${audit.ok ? "status-pill--ok" : "status-pill--danger"}`}>
|
||||||
{audit.ok ? "允许" : "拒绝"}
|
{audit.ok ? "允许" : "拒绝"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</span>
|
||||||
<p className="muted">
|
<span className="entity-main-cell">
|
||||||
{audit.node_name ?? "未知节点"} / {audit.username ?? audit.auth_id ?? "-"}
|
<strong>{audit.node_name ?? "未知节点"}</strong>
|
||||||
</p>
|
<span className="muted mono break-all">{audit.username ?? audit.auth_id ?? "-"}</span>
|
||||||
<p className="muted mono">{audit.addr ?? "-"}</p>
|
</span>
|
||||||
</div>
|
<span className="entity-code-cell break-all">{audit.addr ?? "-"}</span>
|
||||||
<div className="align-right">
|
<span className="entity-number-cell">
|
||||||
<p>{audit.reason}</p>
|
<strong>{audit.requested_tx != null ? formatBytes(audit.requested_tx) : "-"}</strong>
|
||||||
<p className="muted">
|
<span>/s</span>
|
||||||
请求速率 {audit.requested_tx != null ? formatBytes(audit.requested_tx) : "-"}
|
</span>
|
||||||
/s
|
<span>{audit.reason}</span>
|
||||||
</p>
|
<span className="entity-muted-cell">{formatLocalDateTime(audit.created_at)}</span>
|
||||||
<p className="muted">{audit.created_at}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
676
app/admin/dashboard/dashboard-charts.tsx
Normal file
676
app/admin/dashboard/dashboard-charts.tsx
Normal file
@ -0,0 +1,676 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { startTransition, useId, useState } from "react";
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
|
type NodeHealth = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
enabled: number;
|
||||||
|
last_error_message: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TrafficTrendRow = {
|
||||||
|
bucket_start: string;
|
||||||
|
node_id: number;
|
||||||
|
auth_id: string;
|
||||||
|
tx_bytes: number;
|
||||||
|
rx_bytes: number;
|
||||||
|
total_bytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TrafficUserOption = {
|
||||||
|
auth_id: string;
|
||||||
|
username: string;
|
||||||
|
total_bytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LoadTrendRow = {
|
||||||
|
bucket_start: string;
|
||||||
|
node_id: number;
|
||||||
|
online_users: number;
|
||||||
|
stream_count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FailureTrendRow = {
|
||||||
|
bucket_start: string;
|
||||||
|
node_id: number;
|
||||||
|
failed_count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TopConsumerRow = {
|
||||||
|
node_id: number;
|
||||||
|
auth_id: string;
|
||||||
|
username: string;
|
||||||
|
tx_bytes: number;
|
||||||
|
rx_bytes: number;
|
||||||
|
total_bytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DashboardChartsProps = {
|
||||||
|
nodeHealth: NodeHealth[];
|
||||||
|
trafficTrend: TrafficTrendRow[];
|
||||||
|
trafficBuckets: string[];
|
||||||
|
trafficUserOptions: TrafficUserOption[];
|
||||||
|
loadTrend: LoadTrendRow[];
|
||||||
|
failureTrend: FailureTrendRow[];
|
||||||
|
topConsumerRows: TopConsumerRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type TrafficPoint = {
|
||||||
|
bucketStart: string;
|
||||||
|
label: string;
|
||||||
|
[key: string]: string | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodePalette = [
|
||||||
|
"#c96233",
|
||||||
|
"#2b6c80",
|
||||||
|
"#5d7b63",
|
||||||
|
"#7a3116",
|
||||||
|
"#6f5aa8",
|
||||||
|
"#9c6b12",
|
||||||
|
"#2f5d52",
|
||||||
|
"#8a3f55",
|
||||||
|
];
|
||||||
|
|
||||||
|
const axisTick = {
|
||||||
|
fill: "#3f4b57",
|
||||||
|
fontSize: 11,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tooltipStyle = {
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "1px solid rgba(24, 34, 44, 0.12)",
|
||||||
|
backgroundColor: "rgba(255, 251, 245, 0.96)",
|
||||||
|
boxShadow: "0 12px 34px rgba(51, 35, 18, 0.1)",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatBytes(bytes: number) {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
|
||||||
|
const units = ["KB", "MB", "GB", "TB", "PB"];
|
||||||
|
let value = bytes / 1024;
|
||||||
|
let unit = units[0];
|
||||||
|
|
||||||
|
for (let index = 1; index < units.length && value >= 1024; index += 1) {
|
||||||
|
value /= 1024;
|
||||||
|
unit = units[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${unit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCompactBytes(bytes: number) {
|
||||||
|
if (bytes < 1024) return `${bytes}B`;
|
||||||
|
|
||||||
|
const units = ["KB", "MB", "GB", "TB", "PB"];
|
||||||
|
let value = bytes / 1024;
|
||||||
|
let unit = units[0];
|
||||||
|
|
||||||
|
for (let index = 1; index < units.length && value >= 1024; index += 1) {
|
||||||
|
value /= 1024;
|
||||||
|
unit = units[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)}${unit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBucketLabel(value: string, mode: "hour" | "day") {
|
||||||
|
const date = new Date(value);
|
||||||
|
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
...(mode === "hour"
|
||||||
|
? { hour: "2-digit", minute: "2-digit", hourCycle: "h23" }
|
||||||
|
: {}),
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateLabel(value: string, maxLength = 12) {
|
||||||
|
if (value.length <= maxLength) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${value.slice(0, maxLength)}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeColor(index: number) {
|
||||||
|
return nodePalette[index % nodePalette.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeDataKey(nodeId: number) {
|
||||||
|
return `node_${nodeId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesUser(row: { auth_id: string }, selectedAuthId: string) {
|
||||||
|
return selectedAuthId === "all" || row.auth_id === selectedAuthId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNodeTrafficSeries(
|
||||||
|
rows: TrafficTrendRow[],
|
||||||
|
buckets: string[],
|
||||||
|
nodes: NodeHealth[],
|
||||||
|
selectedAuthId: string,
|
||||||
|
) {
|
||||||
|
const series = buckets.map((bucketStart) => {
|
||||||
|
const point: TrafficPoint = {
|
||||||
|
bucketStart,
|
||||||
|
label: formatBucketLabel(bucketStart, "hour"),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
point[getNodeDataKey(node.id)] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return point;
|
||||||
|
});
|
||||||
|
const pointMap = new Map(series.map((point) => [point.bucketStart, point]));
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!matchesUser(row, selectedAuthId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const point = pointMap.get(row.bucket_start);
|
||||||
|
|
||||||
|
if (!point) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = getNodeDataKey(row.node_id);
|
||||||
|
point[key] = Number(point[key] ?? 0) + row.total_bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
return series;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateTrafficTotals(rows: TrafficTrendRow[], selectedAuthId: string) {
|
||||||
|
return rows.reduce(
|
||||||
|
(accumulator, row) => {
|
||||||
|
if (!matchesUser(row, selectedAuthId)) {
|
||||||
|
return accumulator;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
txBytes: accumulator.txBytes + row.tx_bytes,
|
||||||
|
rxBytes: accumulator.rxBytes + row.rx_bytes,
|
||||||
|
totalBytes: accumulator.totalBytes + row.total_bytes,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ txBytes: 0, rxBytes: 0, totalBytes: 0 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateNodeTrafficTotals(rows: TrafficTrendRow[], selectedAuthId: string) {
|
||||||
|
const totals = new Map<number, number>();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!matchesUser(row, selectedAuthId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
totals.set(row.node_id, (totals.get(row.node_id) ?? 0) + row.total_bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return totals;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateLoadSeries(rows: LoadTrendRow[]) {
|
||||||
|
const map = new Map<string, {
|
||||||
|
bucketStart: string;
|
||||||
|
onlineUsers: number;
|
||||||
|
streamCount: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const existing = map.get(row.bucket_start) ?? {
|
||||||
|
bucketStart: row.bucket_start,
|
||||||
|
onlineUsers: 0,
|
||||||
|
streamCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
existing.onlineUsers += row.online_users;
|
||||||
|
existing.streamCount += row.stream_count;
|
||||||
|
map.set(row.bucket_start, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(map.values())
|
||||||
|
.sort((left, right) => left.bucketStart.localeCompare(right.bucketStart))
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
label: formatBucketLabel(item.bucketStart, "hour"),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateFailureSeries(rows: FailureTrendRow[]) {
|
||||||
|
const map = new Map<string, { bucketStart: string; failedCount: number }>();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const existing = map.get(row.bucket_start) ?? {
|
||||||
|
bucketStart: row.bucket_start,
|
||||||
|
failedCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
existing.failedCount += row.failed_count;
|
||||||
|
map.set(row.bucket_start, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(map.values())
|
||||||
|
.sort((left, right) => left.bucketStart.localeCompare(right.bucketStart))
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
label: formatBucketLabel(item.bucketStart, "day"),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateTopConsumers(rows: TopConsumerRow[], selectedAuthId: string) {
|
||||||
|
const map = new Map<string, {
|
||||||
|
authId: string;
|
||||||
|
username: string;
|
||||||
|
txBytes: number;
|
||||||
|
rxBytes: number;
|
||||||
|
totalBytes: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!matchesUser(row, selectedAuthId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = map.get(row.auth_id) ?? {
|
||||||
|
authId: row.auth_id,
|
||||||
|
username: row.username,
|
||||||
|
txBytes: 0,
|
||||||
|
rxBytes: 0,
|
||||||
|
totalBytes: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
existing.txBytes += row.tx_bytes;
|
||||||
|
existing.rxBytes += row.rx_bytes;
|
||||||
|
existing.totalBytes += row.total_bytes;
|
||||||
|
map.set(row.auth_id, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(map.values())
|
||||||
|
.sort((left, right) => right.totalBytes - left.totalBytes)
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
label: truncateLabel(item.username),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardCharts({
|
||||||
|
nodeHealth,
|
||||||
|
trafficTrend,
|
||||||
|
trafficBuckets,
|
||||||
|
trafficUserOptions,
|
||||||
|
loadTrend,
|
||||||
|
failureTrend,
|
||||||
|
topConsumerRows,
|
||||||
|
}: DashboardChartsProps) {
|
||||||
|
const userSelectId = useId();
|
||||||
|
const [selectedAuthId, setSelectedAuthId] = useState("all");
|
||||||
|
const [hiddenNodeIds, setHiddenNodeIds] = useState<Set<number>>(() => new Set());
|
||||||
|
const selectedUser = selectedAuthId === "all"
|
||||||
|
? null
|
||||||
|
: trafficUserOptions.find((user) => user.auth_id === selectedAuthId) ?? null;
|
||||||
|
const selectedUserLabel = selectedUser?.username ?? "全部用户";
|
||||||
|
|
||||||
|
const trafficSeries = buildNodeTrafficSeries(
|
||||||
|
trafficTrend,
|
||||||
|
trafficBuckets,
|
||||||
|
nodeHealth,
|
||||||
|
selectedAuthId,
|
||||||
|
);
|
||||||
|
const trafficTotals = aggregateTrafficTotals(trafficTrend, selectedAuthId);
|
||||||
|
const nodeTrafficTotals = aggregateNodeTrafficTotals(trafficTrend, selectedAuthId);
|
||||||
|
const loadSeries = aggregateLoadSeries(loadTrend);
|
||||||
|
const failureSeries = aggregateFailureSeries(failureTrend);
|
||||||
|
const consumerSeries = aggregateTopConsumers(topConsumerRows, selectedAuthId);
|
||||||
|
const visibleNodes = nodeHealth.filter((node) => !hiddenNodeIds.has(node.id));
|
||||||
|
|
||||||
|
const peakLoad = loadSeries.reduce(
|
||||||
|
(accumulator, item) => ({
|
||||||
|
onlineUsers: Math.max(accumulator.onlineUsers, item.onlineUsers),
|
||||||
|
streamCount: Math.max(accumulator.streamCount, item.streamCount),
|
||||||
|
}),
|
||||||
|
{ onlineUsers: 0, streamCount: 0 },
|
||||||
|
);
|
||||||
|
const totalFailures = failureSeries.reduce(
|
||||||
|
(accumulator, item) => accumulator + item.failedCount,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const healthyNodes = nodeHealth.filter(
|
||||||
|
(node) => node.enabled && !node.last_error_message,
|
||||||
|
).length;
|
||||||
|
const disabledNodes = nodeHealth.filter((node) => !node.enabled).length;
|
||||||
|
const degradedNodes = nodeHealth.length - healthyNodes - disabledNodes;
|
||||||
|
const statusTone = degradedNodes > 0 ? "danger" : disabledNodes > 0 ? "warm" : "ok";
|
||||||
|
const statusLabel = `${healthyNodes}/${nodeHealth.length} 节点健康`;
|
||||||
|
const hasTrafficData = trafficTotals.totalBytes > 0;
|
||||||
|
const hasVisibleTrafficData = visibleNodes.some((node) => {
|
||||||
|
const key = getNodeDataKey(node.id);
|
||||||
|
return trafficSeries.some((point) => Number(point[key] ?? 0) > 0);
|
||||||
|
});
|
||||||
|
const hasLoadData = loadSeries.some(
|
||||||
|
(item) => item.onlineUsers > 0 || item.streamCount > 0,
|
||||||
|
);
|
||||||
|
const hasFailureData = failureSeries.some((item) => item.failedCount > 0);
|
||||||
|
const hasConsumerData = consumerSeries.length > 0;
|
||||||
|
|
||||||
|
function toggleNodeLine(nodeId: number) {
|
||||||
|
startTransition(() => {
|
||||||
|
setHiddenNodeIds((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
|
||||||
|
if (next.has(nodeId)) {
|
||||||
|
next.delete(nodeId);
|
||||||
|
} else {
|
||||||
|
next.add(nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel-card dashboard-analytics">
|
||||||
|
<div className="dashboard-toolbar">
|
||||||
|
<div className="stack-tight">
|
||||||
|
<div className="section-title section-title--top">
|
||||||
|
<div>
|
||||||
|
<h3>流量与节点趋势</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="badge-row">
|
||||||
|
<span className="status-pill status-pill--warm">{selectedUserLabel}</span>
|
||||||
|
<span className={`status-pill status-pill--${statusTone}`}>{statusLabel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="chart-filter">
|
||||||
|
<label htmlFor={userSelectId}>用户</label>
|
||||||
|
<select
|
||||||
|
id={userSelectId}
|
||||||
|
value={selectedAuthId}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextValue = event.target.value;
|
||||||
|
startTransition(() => {
|
||||||
|
setSelectedAuthId(nextValue);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="all">全部用户</option>
|
||||||
|
{trafficUserOptions.map((user) => (
|
||||||
|
<option key={user.auth_id} value={user.auth_id}>
|
||||||
|
{user.username} / {formatCompactBytes(user.total_bytes)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dashboard-glance-grid">
|
||||||
|
<article className="dashboard-glance-card dashboard-glance-card--warm">
|
||||||
|
<span>24h 总流量</span>
|
||||||
|
<strong>{formatBytes(trafficTotals.totalBytes)}</strong>
|
||||||
|
<small>TX {formatBytes(trafficTotals.txBytes)} / RX {formatBytes(trafficTotals.rxBytes)}</small>
|
||||||
|
</article>
|
||||||
|
<article className="dashboard-glance-card dashboard-glance-card--ink">
|
||||||
|
<span>24h 峰值在线</span>
|
||||||
|
<strong>{peakLoad.onlineUsers}</strong>
|
||||||
|
<small>峰值流数 {peakLoad.streamCount}</small>
|
||||||
|
</article>
|
||||||
|
<article className="dashboard-glance-card dashboard-glance-card--sage">
|
||||||
|
<span>14d 鉴权失败</span>
|
||||||
|
<strong>{totalFailures}</strong>
|
||||||
|
<small>失败请求数</small>
|
||||||
|
</article>
|
||||||
|
<article className="dashboard-glance-card dashboard-glance-card--paper">
|
||||||
|
<span>节点状态</span>
|
||||||
|
<strong>{statusLabel}</strong>
|
||||||
|
<small>异常 {degradedNodes} / 已禁用 {disabledNodes}</small>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dashboard-chart-grid">
|
||||||
|
<article className="dashboard-chart-card dashboard-chart-card--wide">
|
||||||
|
<div className="dashboard-chart-card__head">
|
||||||
|
<div className="section-title">
|
||||||
|
<h4>节点流量</h4>
|
||||||
|
<span className="status-pill status-pill--warm">最近 24 小时</span>
|
||||||
|
</div>
|
||||||
|
<div className="node-line-toggles">
|
||||||
|
{nodeHealth.map((node, index) => {
|
||||||
|
const hidden = hiddenNodeIds.has(node.id);
|
||||||
|
const color = getNodeColor(index);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={node.id}
|
||||||
|
type="button"
|
||||||
|
className={`node-line-toggle ${hidden ? "is-hidden" : ""}`}
|
||||||
|
aria-pressed={!hidden}
|
||||||
|
onClick={() => toggleNodeLine(node.id)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="node-line-toggle__swatch"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
<span>{node.name}</span>
|
||||||
|
<small>{formatCompactBytes(nodeTrafficTotals.get(node.id) ?? 0)}</small>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{hasTrafficData && hasVisibleTrafficData ? (
|
||||||
|
<div className="chart-shell chart-shell--wide">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={trafficSeries} margin={{ top: 6, right: 12, left: 0, bottom: 0 }}>
|
||||||
|
<CartesianGrid stroke="rgba(24, 34, 44, 0.08)" strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
minTickGap={22}
|
||||||
|
tick={axisTick}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickFormatter={formatCompactBytes}
|
||||||
|
tick={axisTick}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={58}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={tooltipStyle}
|
||||||
|
formatter={(value, name) => [
|
||||||
|
formatBytes(Number(value ?? 0)),
|
||||||
|
String(name),
|
||||||
|
]}
|
||||||
|
labelFormatter={(label) => `时间 ${label}`}
|
||||||
|
/>
|
||||||
|
{visibleNodes.map((node, index) => (
|
||||||
|
<Line
|
||||||
|
key={node.id}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={getNodeDataKey(node.id)}
|
||||||
|
name={node.name}
|
||||||
|
stroke={getNodeColor(index)}
|
||||||
|
strokeWidth={2.2}
|
||||||
|
dot={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="chart-empty">暂无可见节点流量。</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="dashboard-chart-card">
|
||||||
|
<div className="section-title">
|
||||||
|
<h4>在线负载</h4>
|
||||||
|
<span className="status-pill status-pill--ink">24h</span>
|
||||||
|
</div>
|
||||||
|
{hasLoadData ? (
|
||||||
|
<div className="chart-shell">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={loadSeries} margin={{ top: 6, right: 12, left: 0, bottom: 0 }}>
|
||||||
|
<CartesianGrid stroke="rgba(24, 34, 44, 0.08)" strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
minTickGap={22}
|
||||||
|
tick={axisTick}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis tick={axisTick} tickLine={false} axisLine={false} width={36} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={tooltipStyle}
|
||||||
|
formatter={(value, name) => [
|
||||||
|
`${Number(value ?? 0)}`,
|
||||||
|
String(name) === "onlineUsers" ? "在线用户" : "流数量",
|
||||||
|
]}
|
||||||
|
labelFormatter={(label) => `时间 ${label}`}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="onlineUsers"
|
||||||
|
name="在线用户"
|
||||||
|
stroke="#18222c"
|
||||||
|
strokeWidth={2.2}
|
||||||
|
dot={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="streamCount"
|
||||||
|
name="流数量"
|
||||||
|
stroke="#5d7b63"
|
||||||
|
strokeWidth={2.2}
|
||||||
|
dot={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="chart-empty">暂无同步快照。</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="dashboard-chart-card">
|
||||||
|
<div className="section-title">
|
||||||
|
<h4>鉴权失败</h4>
|
||||||
|
<span className="status-pill status-pill--danger">14d</span>
|
||||||
|
</div>
|
||||||
|
{hasFailureData ? (
|
||||||
|
<div className="chart-shell">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={failureSeries} margin={{ top: 6, right: 12, left: 0, bottom: 0 }}>
|
||||||
|
<CartesianGrid stroke="rgba(24, 34, 44, 0.08)" strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="label" tick={axisTick} tickLine={false} axisLine={false} />
|
||||||
|
<YAxis tick={axisTick} tickLine={false} axisLine={false} width={36} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={tooltipStyle}
|
||||||
|
formatter={(value) => [`${Number(value ?? 0)} 次`, "失败次数"]}
|
||||||
|
labelFormatter={(label) => `日期 ${label}`}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="failedCount"
|
||||||
|
name="失败次数"
|
||||||
|
fill="#af2d21"
|
||||||
|
radius={[6, 6, 0, 0]}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="chart-empty">暂无鉴权失败。</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="dashboard-chart-card">
|
||||||
|
<div className="section-title">
|
||||||
|
<h4>用户流量排行</h4>
|
||||||
|
<span className="status-pill status-pill--sage">7d</span>
|
||||||
|
</div>
|
||||||
|
{hasConsumerData ? (
|
||||||
|
<div className="chart-shell">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart
|
||||||
|
data={consumerSeries}
|
||||||
|
layout="vertical"
|
||||||
|
margin={{ top: 6, right: 14, left: 0, bottom: 0 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid stroke="rgba(24, 34, 44, 0.08)" strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
tickFormatter={formatCompactBytes}
|
||||||
|
tick={axisTick}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="label"
|
||||||
|
tick={axisTick}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={86}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={tooltipStyle}
|
||||||
|
formatter={(value, name, item) => {
|
||||||
|
const payload = item?.payload as { username?: string } | undefined;
|
||||||
|
|
||||||
|
return [
|
||||||
|
formatBytes(Number(value ?? 0)),
|
||||||
|
String(name) === "totalBytes"
|
||||||
|
? payload?.username ?? "累计流量"
|
||||||
|
: String(name),
|
||||||
|
];
|
||||||
|
}}
|
||||||
|
labelFormatter={() => "用户"}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="totalBytes"
|
||||||
|
name="totalBytes"
|
||||||
|
fill="#7a3116"
|
||||||
|
radius={[0, 6, 6, 0]}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="chart-empty">暂无用户流量。</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,5 +1,8 @@
|
|||||||
|
import { formatLocalDateTime } from "@/lib/datetime";
|
||||||
import { formatBytes, getDashboardMetrics } from "@/lib/store";
|
import { formatBytes, getDashboardMetrics } from "@/lib/store";
|
||||||
|
|
||||||
|
import { DashboardCharts } from "./dashboard-charts";
|
||||||
|
|
||||||
function getNodePill(node: {
|
function getNodePill(node: {
|
||||||
enabled: number;
|
enabled: number;
|
||||||
last_error_message: string | null;
|
last_error_message: string | null;
|
||||||
@ -19,17 +22,13 @@ export default async function DashboardPage() {
|
|||||||
const data = await getDashboardMetrics();
|
const data = await getDashboardMetrics();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="page-stack">
|
<main className="page-stack dashboard-page">
|
||||||
<section className="hero-panel">
|
<section className="hero-panel">
|
||||||
<div className="hero-copy">
|
<div className="hero-copy">
|
||||||
<span className="eyebrow">OVERVIEW</span>
|
<span className="eyebrow">概览</span>
|
||||||
<h2>仪表盘</h2>
|
<h2>仪表盘</h2>
|
||||||
<p className="page-lede">
|
|
||||||
汇总多节点在线、鉴权失败、同步错误和用户用量,作为当前运行态的统一入口。
|
|
||||||
</p>
|
|
||||||
<div className="badge-row">
|
<div className="badge-row">
|
||||||
<span className="status-pill status-pill--ok">HTTP 鉴权在线</span>
|
<span className="status-pill status-pill--ok">在线 {data.totals.total_online_connections}</span>
|
||||||
<span className="status-pill status-pill--neutral">轮询聚合已启用</span>
|
|
||||||
<span className="status-pill status-pill--warm">节点 {data.totals.enabled_nodes}</span>
|
<span className="status-pill status-pill--warm">节点 {data.totals.enabled_nodes}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -59,15 +58,25 @@ export default async function DashboardPage() {
|
|||||||
<article className="metric-card metric-card--sage">
|
<article className="metric-card metric-card--sage">
|
||||||
<span>启用节点</span>
|
<span>启用节点</span>
|
||||||
<strong>{data.totals.enabled_nodes}</strong>
|
<strong>{data.totals.enabled_nodes}</strong>
|
||||||
<small>轮询与 kick 生效范围</small>
|
<small>当前可用</small>
|
||||||
</article>
|
</article>
|
||||||
<article className="metric-card metric-card--paper">
|
<article className="metric-card metric-card--paper">
|
||||||
<span>累计流量</span>
|
<span>累计流量</span>
|
||||||
<strong>{formatBytes(data.totals.total_traffic)}</strong>
|
<strong>{formatBytes(data.totals.total_traffic)}</strong>
|
||||||
<small>已聚合到 SQLite</small>
|
<small>全量累计</small>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<DashboardCharts
|
||||||
|
nodeHealth={data.nodeHealth}
|
||||||
|
trafficTrend={data.trafficTrend}
|
||||||
|
trafficBuckets={data.trafficBuckets}
|
||||||
|
trafficUserOptions={data.trafficUserOptions}
|
||||||
|
loadTrend={data.loadTrend}
|
||||||
|
failureTrend={data.failureTrend}
|
||||||
|
topConsumerRows={data.topConsumerRows}
|
||||||
|
/>
|
||||||
|
|
||||||
<section className="panel-grid">
|
<section className="panel-grid">
|
||||||
<article className="panel-card">
|
<article className="panel-card">
|
||||||
<div className="section-title">
|
<div className="section-title">
|
||||||
@ -90,7 +99,7 @@ export default async function DashboardPage() {
|
|||||||
在线 {node.last_online_users} / 流 {node.last_stream_count}
|
在线 {node.last_online_users} / 流 {node.last_stream_count}
|
||||||
</p>
|
</p>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
最近成功 {node.last_sync_ok_at ?? "无"}
|
最近成功 {formatLocalDateTime(node.last_sync_ok_at)}
|
||||||
</p>
|
</p>
|
||||||
{node.last_error_message ? (
|
{node.last_error_message ? (
|
||||||
<p className="error-text">{node.last_error_message}</p>
|
<p className="error-text">{node.last_error_message}</p>
|
||||||
@ -138,7 +147,7 @@ export default async function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="align-right">
|
<div className="align-right">
|
||||||
<p className="muted mono">{item.addr ?? "-"}</p>
|
<p className="muted mono">{item.addr ?? "-"}</p>
|
||||||
<p className="muted">{item.created_at}</p>
|
<p className="muted">{formatLocalDateTime(item.created_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -160,7 +169,7 @@ export default async function DashboardPage() {
|
|||||||
<p>
|
<p>
|
||||||
+{formatBytes(item.tx_bytes + item.rx_bytes)}
|
+{formatBytes(item.tx_bytes + item.rx_bytes)}
|
||||||
</p>
|
</p>
|
||||||
<p className="muted">{item.created_at}</p>
|
<p className="muted">{formatLocalDateTime(item.created_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -181,7 +190,7 @@ export default async function DashboardPage() {
|
|||||||
<strong>{item.node_name}</strong>
|
<strong>{item.node_name}</strong>
|
||||||
<p className="error-text">{item.error_message ?? "unknown error"}</p>
|
<p className="error-text">{item.error_message ?? "unknown error"}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="muted">{item.created_at}</p>
|
<p className="muted">{formatLocalDateTime(item.created_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -3,11 +3,11 @@ import { requireAdminAccess } from "@/lib/session";
|
|||||||
import { AdminNav } from "./admin-nav";
|
import { AdminNav } from "./admin-nav";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/admin/dashboard", label: "仪表盘", meta: "运行态与健康度" },
|
{ href: "/admin/dashboard", label: "仪表盘" },
|
||||||
{ href: "/admin/users", label: "用户管理", meta: "凭证、额度与订阅" },
|
{ href: "/admin/users", label: "用户管理" },
|
||||||
{ href: "/admin/nodes", label: "节点管理", meta: "接入、鉴权与导出" },
|
{ href: "/admin/nodes", label: "节点管理" },
|
||||||
{ href: "/admin/subscriptions", label: "订阅配置", meta: "模板与代理分组" },
|
{ href: "/admin/subscriptions", label: "订阅配置" },
|
||||||
{ href: "/admin/audits", label: "审计日志", meta: "鉴权失败与追踪" },
|
{ href: "/admin/audits", label: "审计日志" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default async function AdminLayout({
|
export default async function AdminLayout({
|
||||||
@ -23,9 +23,9 @@ export default async function AdminLayout({
|
|||||||
<div className="brand-block">
|
<div className="brand-block">
|
||||||
<span className="eyebrow">HY2 PANEL</span>
|
<span className="eyebrow">HY2 PANEL</span>
|
||||||
<h1>鹅梯控制台</h1>
|
<h1>鹅梯控制台</h1>
|
||||||
<p className="muted">
|
<div className="badge-row">
|
||||||
管理员 {session.username} 已登录
|
<span className="status-pill status-pill--ink">{session.username}</span>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AdminNav items={navItems} />
|
<AdminNav items={navItems} />
|
||||||
<form action="/logout" method="post">
|
<form action="/logout" method="post">
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { getRequestOrigin } from "@/lib/request-origin";
|
import { getRequestOrigin } from "@/lib/request-origin";
|
||||||
|
import { formatLocalDateTime } from "@/lib/datetime";
|
||||||
import { buildNodeHy2Config } from "@/lib/subscription";
|
import { buildNodeHy2Config } from "@/lib/subscription";
|
||||||
import { buildNodeAuthUrl, buildTrafficStatsUrl, listNodes } from "@/lib/store";
|
import { buildNodeAuthUrl, buildTrafficStatsUrl, listNodes } from "@/lib/store";
|
||||||
|
|
||||||
@ -23,17 +24,14 @@ export default async function NodesPage() {
|
|||||||
const [nodes, origin] = await Promise.all([listNodes(), getRequestOrigin()]);
|
const [nodes, origin] = await Promise.all([listNodes(), getRequestOrigin()]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="page-stack">
|
<main className="page-stack management-page">
|
||||||
<section className="hero-panel">
|
<section className="hero-panel">
|
||||||
<div className="hero-copy">
|
<div className="hero-copy">
|
||||||
<span className="eyebrow">NODES</span>
|
<span className="eyebrow">节点</span>
|
||||||
<h2>节点管理</h2>
|
<h2>节点管理</h2>
|
||||||
<p className="page-lede">
|
|
||||||
统一维护 hy2 节点监听参数、鉴权 URL、trafficStats 接入和客户端导出配置。
|
|
||||||
</p>
|
|
||||||
<div className="badge-row">
|
<div className="badge-row">
|
||||||
<span className="status-pill status-pill--neutral">URL 自动推导</span>
|
<span className="status-pill status-pill--ok">启用 {nodes.filter((node) => node.enabled).length}</span>
|
||||||
<span className="status-pill status-pill--warm">Secret 自动生成</span>
|
<span className="status-pill status-pill--neutral">总数 {nodes.length}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="hero-aside">
|
<div className="hero-aside">
|
||||||
@ -48,11 +46,15 @@ export default async function NodesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="panel-card">
|
<section className="panel-card compact-create-panel">
|
||||||
<div className="section-title">
|
<details>
|
||||||
|
<summary className="compact-create-summary">
|
||||||
|
<div>
|
||||||
<h3>新增节点</h3>
|
<h3>新增节点</h3>
|
||||||
</div>
|
</div>
|
||||||
<form action={createNodeAction} className="form-grid">
|
<span className="status-pill status-pill--warm">展开</span>
|
||||||
|
</summary>
|
||||||
|
<form action={createNodeAction} className="dense-form-grid dense-form-grid--nodes">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Slug</span>
|
<span>Slug</span>
|
||||||
<input name="slug" placeholder="tokyo-01" required />
|
<input name="slug" placeholder="tokyo-01" required />
|
||||||
@ -71,14 +73,7 @@ export default async function NodesPage() {
|
|||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>对外端口</span>
|
<span>对外端口</span>
|
||||||
<input
|
<input name="server_port" type="number" min="1" max="65535" defaultValue={443} required />
|
||||||
name="server_port"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="65535"
|
|
||||||
defaultValue={443}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Clash 节点名</span>
|
<span>Clash 节点名</span>
|
||||||
@ -102,7 +97,7 @@ export default async function NodesPage() {
|
|||||||
</label>
|
</label>
|
||||||
<label className="checkbox">
|
<label className="checkbox">
|
||||||
<input name="skip_cert_verify" type="checkbox" />
|
<input name="skip_cert_verify" type="checkbox" />
|
||||||
<span>客户端跳过证书校验</span>
|
<span>跳过证书校验</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>trafficStats 监听</span>
|
<span>trafficStats 监听</span>
|
||||||
@ -110,10 +105,7 @@ export default async function NodesPage() {
|
|||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>trafficStats URL</span>
|
<span>trafficStats URL</span>
|
||||||
<input
|
<input name="traffic_stats_url" placeholder="留空自动推导" />
|
||||||
name="traffic_stats_url"
|
|
||||||
placeholder="留空自动按客户端地址 + 监听端口生成"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>trafficStats Secret</span>
|
<span>trafficStats Secret</span>
|
||||||
@ -121,14 +113,7 @@ export default async function NodesPage() {
|
|||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>轮询间隔(秒)</span>
|
<span>轮询间隔(秒)</span>
|
||||||
<input
|
<input name="poll_interval_seconds" type="number" min="10" max="30" defaultValue={15} required />
|
||||||
name="poll_interval_seconds"
|
|
||||||
type="number"
|
|
||||||
min="10"
|
|
||||||
max="30"
|
|
||||||
defaultValue={15}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label className="checkbox">
|
<label className="checkbox">
|
||||||
<input name="enabled" type="checkbox" defaultChecked />
|
<input name="enabled" type="checkbox" defaultChecked />
|
||||||
@ -138,9 +123,24 @@ export default async function NodesPage() {
|
|||||||
创建节点
|
创建节点
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="stack-lg">
|
<section className="panel-card dense-panel">
|
||||||
|
<div className="section-title">
|
||||||
|
<h3>节点列表</h3>
|
||||||
|
<span className="status-pill status-pill--neutral">{nodes.length} 个节点</span>
|
||||||
|
</div>
|
||||||
|
<div className="entity-table entity-table--nodes">
|
||||||
|
<div className="entity-table__head node-row-grid">
|
||||||
|
<span>节点</span>
|
||||||
|
<span>状态</span>
|
||||||
|
<span>对外地址</span>
|
||||||
|
<span>trafficStats</span>
|
||||||
|
<span>在线/流</span>
|
||||||
|
<span>最近同步</span>
|
||||||
|
<span>操作</span>
|
||||||
|
</div>
|
||||||
{nodes.map((node) => {
|
{nodes.map((node) => {
|
||||||
const authUrl = buildNodeAuthUrl(node.slug, node.auth_token, origin);
|
const authUrl = buildNodeAuthUrl(node.slug, node.auth_token, origin);
|
||||||
const suggestedTrafficStatsUrl = buildTrafficStatsUrl(
|
const suggestedTrafficStatsUrl = buildTrafficStatsUrl(
|
||||||
@ -149,42 +149,50 @@ export default async function NodesPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article key={node.id} className="panel-card entity-card">
|
<details key={node.id} className="entity-table__item">
|
||||||
<div className="section-title section-title--top">
|
<summary className="entity-table__summary node-row-grid">
|
||||||
<div>
|
<span className="entity-main-cell">
|
||||||
<h3>{node.name}</h3>
|
<strong>{node.name}</strong>
|
||||||
<p className="muted mono">/{node.slug}</p>
|
<span className="muted mono">/{node.slug}</span>
|
||||||
</div>
|
</span>
|
||||||
<div className="align-right stack-tight">
|
<span className="badge-row entity-status-cell">
|
||||||
<div className="badge-row badge-row--end">
|
|
||||||
{getNodePills(node).map((pill) => (
|
{getNodePills(node).map((pill) => (
|
||||||
<span key={pill.label} className={pill.className}>
|
<span key={pill.label} className={pill.className}>
|
||||||
{pill.label}
|
{pill.label}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
</span>
|
||||||
|
<span className="entity-code-cell">
|
||||||
|
{node.server_host}:{node.server_port}
|
||||||
|
<small>hy2 {node.hy2_listen}</small>
|
||||||
|
</span>
|
||||||
|
<span className="entity-code-cell break-all">
|
||||||
|
{node.traffic_stats_url}
|
||||||
|
<small>{node.traffic_stats_listen}</small>
|
||||||
|
</span>
|
||||||
|
<span className="entity-number-cell">
|
||||||
|
<strong>{node.last_online_users} / {node.last_stream_count}</strong>
|
||||||
|
<span>用户 / 流</span>
|
||||||
|
</span>
|
||||||
|
<span className="entity-muted-cell">
|
||||||
|
{formatLocalDateTime(node.last_sync_ok_at)}
|
||||||
|
{node.last_error_at ? <small>错误 {formatLocalDateTime(node.last_error_at)}</small> : null}
|
||||||
|
</span>
|
||||||
|
<span className="entity-expand-label">编辑</span>
|
||||||
|
</summary>
|
||||||
|
<div className="entity-table__details">
|
||||||
|
<div className="dense-code-grid">
|
||||||
|
<div className="dense-code-line">
|
||||||
|
<span>鉴权 URL</span>
|
||||||
|
<code>{authUrl}</code>
|
||||||
</div>
|
</div>
|
||||||
<p className="muted">
|
<div className="dense-code-line">
|
||||||
最近同步 {node.last_sync_ok_at ?? "无"} / 错误{" "}
|
<span>自动推导 trafficStats</span>
|
||||||
{node.last_error_at ?? "无"}
|
<code>{suggestedTrafficStatsUrl}</code>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="summary-grid">
|
|
||||||
<div className="metric-inline">
|
|
||||||
<span>hy2 监听</span>
|
|
||||||
<strong className="mono">{node.hy2_listen}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="metric-inline">
|
|
||||||
<span>对外地址</span>
|
|
||||||
<strong className="mono">{node.server_host}:{node.server_port}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="metric-inline">
|
|
||||||
<span>trafficStats</span>
|
|
||||||
<strong className="mono break-all">{node.traffic_stats_url}</strong>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form action={updateNodeAction} className="form-grid compact">
|
<form action={updateNodeAction} className="dense-form-grid dense-form-grid--nodes">
|
||||||
<input name="id" type="hidden" value={node.id} />
|
<input name="id" type="hidden" value={node.id} />
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Slug</span>
|
<span>Slug</span>
|
||||||
@ -200,22 +208,11 @@ export default async function NodesPage() {
|
|||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>hy2 监听地址</span>
|
<span>hy2 监听地址</span>
|
||||||
<input
|
<input name="hy2_listen" defaultValue={node.hy2_listen} placeholder=":443 或 0.0.0.0:443" />
|
||||||
name="hy2_listen"
|
|
||||||
defaultValue={node.hy2_listen}
|
|
||||||
placeholder=":443 或 0.0.0.0:443"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>对外端口</span>
|
<span>对外端口</span>
|
||||||
<input
|
<input name="server_port" type="number" min="1" max="65535" defaultValue={node.server_port} required />
|
||||||
name="server_port"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="65535"
|
|
||||||
defaultValue={node.server_port}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Clash 节点名</span>
|
<span>Clash 节点名</span>
|
||||||
@ -227,76 +224,38 @@ export default async function NodesPage() {
|
|||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>上行带宽 Mbps</span>
|
<span>上行带宽 Mbps</span>
|
||||||
<input
|
<input name="bandwidth_up_mbps" type="number" min="1" defaultValue={node.bandwidth_up_mbps} required />
|
||||||
name="bandwidth_up_mbps"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
defaultValue={node.bandwidth_up_mbps}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>下行带宽 Mbps</span>
|
<span>下行带宽 Mbps</span>
|
||||||
<input
|
<input name="bandwidth_down_mbps" type="number" min="1" defaultValue={node.bandwidth_down_mbps} required />
|
||||||
name="bandwidth_down_mbps"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
defaultValue={node.bandwidth_down_mbps}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>SNI</span>
|
<span>SNI</span>
|
||||||
<input name="sni" defaultValue={node.sni ?? ""} />
|
<input name="sni" defaultValue={node.sni ?? ""} />
|
||||||
</label>
|
</label>
|
||||||
<label className="checkbox">
|
<label className="checkbox">
|
||||||
<input
|
<input name="skip_cert_verify" type="checkbox" defaultChecked={Boolean(node.skip_cert_verify)} />
|
||||||
name="skip_cert_verify"
|
<span>跳过证书校验</span>
|
||||||
type="checkbox"
|
|
||||||
defaultChecked={Boolean(node.skip_cert_verify)}
|
|
||||||
/>
|
|
||||||
<span>客户端跳过证书校验</span>
|
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>trafficStats 监听</span>
|
<span>trafficStats 监听</span>
|
||||||
<input
|
<input name="traffic_stats_listen" defaultValue={node.traffic_stats_listen} required />
|
||||||
name="traffic_stats_listen"
|
|
||||||
defaultValue={node.traffic_stats_listen}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>trafficStats URL</span>
|
<span>trafficStats URL</span>
|
||||||
<input
|
<input name="traffic_stats_url" defaultValue={node.traffic_stats_url} />
|
||||||
name="traffic_stats_url"
|
|
||||||
defaultValue={node.traffic_stats_url}
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>trafficStats Secret</span>
|
<span>trafficStats Secret</span>
|
||||||
<input
|
<input name="traffic_stats_secret" defaultValue={node.traffic_stats_secret} placeholder="留空保持当前值" />
|
||||||
name="traffic_stats_secret"
|
|
||||||
defaultValue={node.traffic_stats_secret}
|
|
||||||
placeholder="留空保持当前值"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>轮询间隔(秒)</span>
|
<span>轮询间隔(秒)</span>
|
||||||
<input
|
<input name="poll_interval_seconds" type="number" min="10" max="30" defaultValue={node.poll_interval_seconds} required />
|
||||||
name="poll_interval_seconds"
|
|
||||||
type="number"
|
|
||||||
min="10"
|
|
||||||
max="30"
|
|
||||||
defaultValue={node.poll_interval_seconds}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label className="checkbox">
|
<label className="checkbox">
|
||||||
<input
|
<input name="enabled" type="checkbox" defaultChecked={Boolean(node.enabled)} />
|
||||||
name="enabled"
|
|
||||||
type="checkbox"
|
|
||||||
defaultChecked={Boolean(node.enabled)}
|
|
||||||
/>
|
|
||||||
<span>启用节点</span>
|
<span>启用节点</span>
|
||||||
</label>
|
</label>
|
||||||
<button className="ghost-button" type="submit">
|
<button className="ghost-button" type="submit">
|
||||||
@ -304,43 +263,33 @@ export default async function NodesPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="instruction-block">
|
<details className="config-collapse">
|
||||||
<strong>节点接入说明</strong>
|
<summary>查看 hy2 config.yaml</summary>
|
||||||
<p className="muted">
|
|
||||||
hy2 HTTP 鉴权 URL 必须独立配置到本节点。面板通过路径识别节点来源,不依赖请求体。
|
|
||||||
</p>
|
|
||||||
<pre>
|
<pre>
|
||||||
{buildNodeHy2Config(node, origin)}
|
{buildNodeHy2Config(node, origin)}
|
||||||
</pre>
|
</pre>
|
||||||
<p className="muted">
|
<a className="ghost-button inline-button" href={`/api/admin/nodes/${node.slug}/config`}>
|
||||||
面板轮询地址:<span className="mono">{node.traffic_stats_url}</span>
|
|
||||||
</p>
|
|
||||||
<p className="muted">
|
|
||||||
自动推导地址:<span className="mono">{suggestedTrafficStatsUrl}</span>
|
|
||||||
</p>
|
|
||||||
<p className="muted">
|
|
||||||
鉴权 URL:<span className="mono">{authUrl}</span>
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
className="ghost-button inline-button"
|
|
||||||
href={`/api/admin/nodes/${node.slug}/config`}
|
|
||||||
>
|
|
||||||
下载 hy2 config.yaml
|
下载 hy2 config.yaml
|
||||||
</a>
|
</a>
|
||||||
|
</details>
|
||||||
|
|
||||||
{node.last_error_message ? (
|
{node.last_error_message ? (
|
||||||
<p className="error-text">{node.last_error_message}</p>
|
<p className="error-text">{node.last_error_message}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div className="entity-action-strip">
|
||||||
<form action={deleteNodeAction}>
|
<form action={deleteNodeAction}>
|
||||||
<input name="id" type="hidden" value={node.id} />
|
<input name="id" type="hidden" value={node.id} />
|
||||||
<button className="danger-button" type="submit">
|
<button className="danger-button" type="submit">
|
||||||
删除节点
|
删除节点
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -6,10 +6,25 @@ import { createStableId } from "@/lib/password";
|
|||||||
import { requireAdminAccess } from "@/lib/session";
|
import { requireAdminAccess } from "@/lib/session";
|
||||||
import {
|
import {
|
||||||
getSubscriptionSettings,
|
getSubscriptionSettings,
|
||||||
|
saveMainProxyGroupMemberSlugs,
|
||||||
saveProxyGroups,
|
saveProxyGroups,
|
||||||
saveSubscriptionSettings,
|
saveSubscriptionSettings,
|
||||||
} from "@/lib/store";
|
} from "@/lib/store";
|
||||||
|
|
||||||
|
function readUniqueMemberSlugs(formData: FormData) {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
return formData.getAll("member_slugs").flatMap((value) => {
|
||||||
|
const slug = String(value).trim();
|
||||||
|
if (!slug || seen.has(slug)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(slug);
|
||||||
|
return [slug];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveSubscriptionSettingsAction(formData: FormData) {
|
export async function saveSubscriptionSettingsAction(formData: FormData) {
|
||||||
await requireAdminAccess();
|
await requireAdminAccess();
|
||||||
|
|
||||||
@ -28,16 +43,7 @@ export async function saveProxyGroupAction(formData: FormData) {
|
|||||||
const settings = await getSubscriptionSettings();
|
const settings = await getSubscriptionSettings();
|
||||||
const id = String(formData.get("id") ?? "").trim() || createStableId();
|
const id = String(formData.get("id") ?? "").trim() || createStableId();
|
||||||
const name = String(formData.get("name") ?? "").trim();
|
const name = String(formData.get("name") ?? "").trim();
|
||||||
const seen = new Set<string>();
|
const memberSlugs = readUniqueMemberSlugs(formData);
|
||||||
const memberSlugs = formData.getAll("member_slugs").flatMap((value) => {
|
|
||||||
const slug = String(value).trim();
|
|
||||||
if (!slug || seen.has(slug)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
seen.add(slug);
|
|
||||||
return [slug];
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return;
|
return;
|
||||||
@ -62,6 +68,13 @@ export async function saveProxyGroupAction(formData: FormData) {
|
|||||||
revalidatePath("/admin/subscriptions");
|
revalidatePath("/admin/subscriptions");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function saveMainProxyGroupAction(formData: FormData) {
|
||||||
|
await requireAdminAccess();
|
||||||
|
|
||||||
|
await saveMainProxyGroupMemberSlugs(readUniqueMemberSlugs(formData));
|
||||||
|
revalidatePath("/admin/subscriptions");
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteProxyGroupAction(formData: FormData) {
|
export async function deleteProxyGroupAction(formData: FormData) {
|
||||||
await requireAdminAccess();
|
await requireAdminAccess();
|
||||||
|
|
||||||
|
|||||||
@ -29,13 +29,10 @@ export default async function AdminSubscriptionsPage() {
|
|||||||
<main className="page-stack">
|
<main className="page-stack">
|
||||||
<section className="hero-panel">
|
<section className="hero-panel">
|
||||||
<div className="hero-copy">
|
<div className="hero-copy">
|
||||||
<span className="eyebrow">SUBSCRIPTIONS</span>
|
<span className="eyebrow">订阅</span>
|
||||||
<h2>订阅与模板</h2>
|
<h2>订阅与模板</h2>
|
||||||
<p className="page-lede">
|
|
||||||
管理 Clash 基础模板、订阅文件名、代理分组以及管理员默认不限量订阅链接。
|
|
||||||
</p>
|
|
||||||
<div className="badge-row">
|
<div className="badge-row">
|
||||||
<span className="status-pill status-pill--ok">动态 proxies</span>
|
<span className="status-pill status-pill--ok">节点 {nodes.length}</span>
|
||||||
<span className="status-pill status-pill--ink">分组 {settings.proxyGroups.length}</span>
|
<span className="status-pill status-pill--ink">分组 {settings.proxyGroups.length}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -69,11 +66,20 @@ export default async function AdminSubscriptionsPage() {
|
|||||||
<section className="panel-card">
|
<section className="panel-card">
|
||||||
<div className="section-title section-title--top">
|
<div className="section-title section-title--top">
|
||||||
<div className="stack-tight">
|
<div className="stack-tight">
|
||||||
<h3>Proxy Groups</h3>
|
<h3>代理分组</h3>
|
||||||
<p className="muted">已选列表从上到下的顺序,就是写入 Clash 配置时的节点顺序。</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stack-lg">
|
<div className="stack-lg">
|
||||||
|
<ProxyGroupEditor
|
||||||
|
mode="main"
|
||||||
|
group={{
|
||||||
|
id: "main",
|
||||||
|
name: settings.profileName,
|
||||||
|
memberSlugs: settings.mainProxyGroupMemberSlugs,
|
||||||
|
}}
|
||||||
|
nodes={proxyGroupNodes}
|
||||||
|
/>
|
||||||
|
|
||||||
{settings.proxyGroups.map((group) => (
|
{settings.proxyGroups.map((group) => (
|
||||||
<ProxyGroupEditor key={group.id} group={group} nodes={proxyGroupNodes} />
|
<ProxyGroupEditor key={group.id} group={group} nodes={proxyGroupNodes} />
|
||||||
))}
|
))}
|
||||||
@ -84,7 +90,7 @@ export default async function AdminSubscriptionsPage() {
|
|||||||
|
|
||||||
<section className="panel-card">
|
<section className="panel-card">
|
||||||
<div className="section-title">
|
<div className="section-title">
|
||||||
<h3>Clash 模板</h3>
|
<h3>订阅模板</h3>
|
||||||
</div>
|
</div>
|
||||||
<form action={saveSubscriptionSettingsAction} className="stack-lg">
|
<form action={saveSubscriptionSettingsAction} className="stack-lg">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
|
|||||||
@ -3,7 +3,11 @@
|
|||||||
import { useDeferredValue, useState } from "react";
|
import { useDeferredValue, useState } from "react";
|
||||||
import { useFormStatus } from "react-dom";
|
import { useFormStatus } from "react-dom";
|
||||||
|
|
||||||
import { deleteProxyGroupAction, saveProxyGroupAction } from "./actions";
|
import {
|
||||||
|
deleteProxyGroupAction,
|
||||||
|
saveMainProxyGroupAction,
|
||||||
|
saveProxyGroupAction,
|
||||||
|
} from "./actions";
|
||||||
|
|
||||||
type ProxyGroupNodeOption = {
|
type ProxyGroupNodeOption = {
|
||||||
slug: string;
|
slug: string;
|
||||||
@ -12,6 +16,7 @@ type ProxyGroupNodeOption = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type ProxyGroupEditorProps = {
|
type ProxyGroupEditorProps = {
|
||||||
|
mode?: "custom" | "main";
|
||||||
group?: {
|
group?: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@ -20,11 +25,14 @@ type ProxyGroupEditorProps = {
|
|||||||
nodes: ProxyGroupNodeOption[];
|
nodes: ProxyGroupNodeOption[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeSelectedSlugs(initialSlugs: string[], nodes: ProxyGroupNodeOption[]) {
|
function normalizeSelectedSlugs(
|
||||||
|
initialSlugs: string[],
|
||||||
|
nodes: ProxyGroupNodeOption[],
|
||||||
|
includeMissingNodes = false,
|
||||||
|
) {
|
||||||
const availableSlugs = new Set(nodes.map((node) => node.slug));
|
const availableSlugs = new Set(nodes.map((node) => node.slug));
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
const selectedSlugs = initialSlugs.filter((slug) => {
|
||||||
return initialSlugs.filter((slug) => {
|
|
||||||
if (!availableSlugs.has(slug) || seen.has(slug)) {
|
if (!availableSlugs.has(slug) || seen.has(slug)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -32,14 +40,33 @@ function normalizeSelectedSlugs(initialSlugs: string[], nodes: ProxyGroupNodeOpt
|
|||||||
seen.add(slug);
|
seen.add(slug);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!includeMissingNodes) {
|
||||||
|
return selectedSlugs;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SaveButton({ isEditing }: { isEditing: boolean }) {
|
for (const node of nodes) {
|
||||||
|
if (!seen.has(node.slug)) {
|
||||||
|
selectedSlugs.push(node.slug);
|
||||||
|
seen.add(node.slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedSlugs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SaveButton({ isEditing, isMainGroup }: { isEditing: boolean; isMainGroup: boolean }) {
|
||||||
const { pending } = useFormStatus();
|
const { pending } = useFormStatus();
|
||||||
|
const label = isMainGroup ? "保存主组顺序" : isEditing ? "保存分组" : "新建分组";
|
||||||
|
const pendingLabel = isMainGroup ? "保存中..." : isEditing ? "保存中..." : "创建中...";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button className={isEditing ? "ghost-button" : "primary-button"} type="submit" disabled={pending}>
|
<button
|
||||||
{pending ? (isEditing ? "保存中..." : "创建中...") : isEditing ? "保存分组" : "新建分组"}
|
className={isEditing && !isMainGroup ? "ghost-button" : "primary-button"}
|
||||||
|
type="submit"
|
||||||
|
disabled={pending}
|
||||||
|
>
|
||||||
|
{pending ? pendingLabel : label}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -54,12 +81,13 @@ function DeleteButton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProxyGroupEditor({ group, nodes }: ProxyGroupEditorProps) {
|
export function ProxyGroupEditor({ mode = "custom", group, nodes }: ProxyGroupEditorProps) {
|
||||||
const isEditing = Boolean(group);
|
const isMainGroup = mode === "main";
|
||||||
|
const isEditing = Boolean(group) && !isMainGroup;
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const deferredQuery = useDeferredValue(query);
|
const deferredQuery = useDeferredValue(query);
|
||||||
const [selectedSlugs, setSelectedSlugs] = useState(() =>
|
const [selectedSlugs, setSelectedSlugs] = useState(() =>
|
||||||
normalizeSelectedSlugs(group?.memberSlugs ?? [], nodes),
|
normalizeSelectedSlugs(group?.memberSlugs ?? [], nodes, isMainGroup),
|
||||||
);
|
);
|
||||||
const selectedSlugSet = new Set(selectedSlugs);
|
const selectedSlugSet = new Set(selectedSlugs);
|
||||||
const nodesBySlug = new Map(nodes.map((node) => [node.slug, node]));
|
const nodesBySlug = new Map(nodes.map((node) => [node.slug, node]));
|
||||||
@ -135,30 +163,35 @@ export function ProxyGroupEditor({ group, nodes }: ProxyGroupEditorProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="instruction-block proxy-group-card">
|
<article className="instruction-block proxy-group-card">
|
||||||
<form action={saveProxyGroupAction} className="stack-md">
|
<form action={isMainGroup ? saveMainProxyGroupAction : saveProxyGroupAction} className="stack-md">
|
||||||
{group ? <input type="hidden" name="id" value={group.id} /> : null}
|
{group && !isMainGroup ? <input type="hidden" name="id" value={group.id} /> : null}
|
||||||
<div className="section-title section-title--top">
|
<div className="section-title section-title--top">
|
||||||
<div className="stack-tight">
|
<div className="stack-tight">
|
||||||
<h4 className="proxy-group-title">{isEditing ? group?.name : "新建分组"}</h4>
|
<h4 className="proxy-group-title">
|
||||||
<p className="muted proxy-group-caption">右侧顺序就是最终写入 Clash 的节点顺序。</p>
|
{isMainGroup ? `默认主分组:${group?.name ?? "FeieProxy"}` : isEditing ? group?.name : "新建分组"}
|
||||||
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<div className="badge-row badge-row--end">
|
<div className="badge-row badge-row--end">
|
||||||
<span className="status-pill status-pill--ink">已选 {selectedNodes.length}</span>
|
<span className="status-pill status-pill--ink">已选 {selectedNodes.length}</span>
|
||||||
|
{!isMainGroup ? (
|
||||||
<span className="status-pill status-pill--warm">未加入 {remainingCount}</span>
|
<span className="status-pill status-pill--warm">未加入 {remainingCount}</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isMainGroup ? (
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{isEditing ? "组名" : "新建分组名"}</span>
|
<span>{isEditing ? "组名" : "新建分组名"}</span>
|
||||||
<input name="name" defaultValue={group?.name ?? ""} placeholder="ChatGPT" required />
|
<input name="name" defaultValue={group?.name ?? ""} placeholder="ChatGPT" required />
|
||||||
</label>
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="proxy-group-builder">
|
<div className={`proxy-group-builder${isMainGroup ? " proxy-group-builder--main" : ""}`}>
|
||||||
|
{!isMainGroup ? (
|
||||||
<section className="proxy-group-panel">
|
<section className="proxy-group-panel">
|
||||||
<div className="proxy-group-panel__header">
|
<div className="proxy-group-panel__header">
|
||||||
<div className="stack-tight">
|
<div className="stack-tight">
|
||||||
<strong>候选节点</strong>
|
<strong>候选节点</strong>
|
||||||
<span className="muted">筛选后点击添加,新节点会追加到分组末尾。</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="proxy-group-toolbar">
|
<div className="proxy-group-toolbar">
|
||||||
<label className="proxy-group-search">
|
<label className="proxy-group-search">
|
||||||
@ -210,13 +243,14 @@ export function ProxyGroupEditor({ group, nodes }: ProxyGroupEditorProps) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="proxy-group-panel proxy-group-panel--selected">
|
<section className="proxy-group-panel proxy-group-panel--selected">
|
||||||
<div className="proxy-group-panel__header">
|
<div className="proxy-group-panel__header">
|
||||||
<div className="stack-tight">
|
<div className="stack-tight">
|
||||||
<strong>已选顺序</strong>
|
<strong>{isMainGroup ? "节点顺序" : "已选顺序"}</strong>
|
||||||
<span className="muted">上移和下移会直接改变最终输出顺序。</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
{!isMainGroup ? (
|
||||||
<button
|
<button
|
||||||
className="ghost-button proxy-toolbar-button"
|
className="ghost-button proxy-toolbar-button"
|
||||||
type="button"
|
type="button"
|
||||||
@ -225,6 +259,7 @@ export function ProxyGroupEditor({ group, nodes }: ProxyGroupEditorProps) {
|
|||||||
>
|
>
|
||||||
清空已选
|
清空已选
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedNodes.length > 0 ? (
|
{selectedNodes.length > 0 ? (
|
||||||
@ -255,6 +290,7 @@ export function ProxyGroupEditor({ group, nodes }: ProxyGroupEditorProps) {
|
|||||||
>
|
>
|
||||||
下移
|
下移
|
||||||
</button>
|
</button>
|
||||||
|
{!isMainGroup ? (
|
||||||
<button
|
<button
|
||||||
className="danger-button proxy-sort-button"
|
className="danger-button proxy-sort-button"
|
||||||
type="button"
|
type="button"
|
||||||
@ -262,6 +298,7 @@ export function ProxyGroupEditor({ group, nodes }: ProxyGroupEditorProps) {
|
|||||||
>
|
>
|
||||||
移除
|
移除
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="member_slugs" value={node.slug} />
|
<input type="hidden" name="member_slugs" value={node.slug} />
|
||||||
</li>
|
</li>
|
||||||
@ -269,19 +306,18 @@ export function ProxyGroupEditor({ group, nodes }: ProxyGroupEditorProps) {
|
|||||||
</ol>
|
</ol>
|
||||||
) : (
|
) : (
|
||||||
<p className="muted proxy-group-empty">
|
<p className="muted proxy-group-empty">
|
||||||
还没有选择节点。先从左侧添加,再在这里调整最终顺序。
|
{isMainGroup ? "暂无可用节点。" : "还没有选择节点。先从左侧添加,再在这里调整最终顺序。"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="action-row">
|
<div className="action-row action-row--end">
|
||||||
<span className="muted">保存后会立即更新订阅模板里的 proxy-groups。</span>
|
<SaveButton isEditing={isEditing} isMainGroup={isMainGroup} />
|
||||||
<SaveButton isEditing={isEditing} />
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{group ? (
|
{group && !isMainGroup ? (
|
||||||
<form action={deleteProxyGroupAction}>
|
<form action={deleteProxyGroupAction}>
|
||||||
<input type="hidden" name="id" value={group.id} />
|
<input type="hidden" name="id" value={group.id} />
|
||||||
<DeleteButton />
|
<DeleteButton />
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { getRequestOrigin } from "@/lib/request-origin";
|
import { getRequestOrigin } from "@/lib/request-origin";
|
||||||
|
import { formatLocalDateTime } from "@/lib/datetime";
|
||||||
import {
|
import {
|
||||||
buildSubscriptionUrl,
|
buildSubscriptionUrl,
|
||||||
describeRemainingTraffic,
|
describeRemainingTraffic,
|
||||||
@ -30,7 +31,7 @@ function getUserPills(user: {
|
|||||||
? { label: "管理员", className: "status-pill status-pill--ink" }
|
? { label: "管理员", className: "status-pill status-pill--ink" }
|
||||||
: null,
|
: null,
|
||||||
user.expires_at
|
user.expires_at
|
||||||
? { label: user.expires_at, className: "status-pill status-pill--neutral" }
|
? { label: formatLocalDateTime(user.expires_at), className: "status-pill status-pill--neutral" }
|
||||||
: { label: "长期有效", className: "status-pill status-pill--warm" },
|
: { label: "长期有效", className: "status-pill status-pill--warm" },
|
||||||
].filter((pill): pill is { label: string; className: string } => Boolean(pill));
|
].filter((pill): pill is { label: string; className: string } => Boolean(pill));
|
||||||
}
|
}
|
||||||
@ -39,17 +40,14 @@ export default async function UsersPage() {
|
|||||||
const [users, origin] = await Promise.all([listUsers(), getRequestOrigin()]);
|
const [users, origin] = await Promise.all([listUsers(), getRequestOrigin()]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="page-stack">
|
<main className="page-stack management-page">
|
||||||
<section className="hero-panel">
|
<section className="hero-panel">
|
||||||
<div className="hero-copy">
|
<div className="hero-copy">
|
||||||
<span className="eyebrow">USERS</span>
|
<span className="eyebrow">用户</span>
|
||||||
<h2>用户管理</h2>
|
<h2>用户管理</h2>
|
||||||
<p className="page-lede">
|
|
||||||
管理用户名:密码、`auth_id`、到期时间、额度、订阅名和用户自助链接。
|
|
||||||
</p>
|
|
||||||
<div className="badge-row">
|
<div className="badge-row">
|
||||||
<span className="status-pill status-pill--ok">自助登录</span>
|
<span className="status-pill status-pill--ok">启用 {users.filter((user) => user.enabled).length}</span>
|
||||||
<span className="status-pill status-pill--neutral">订阅链接按用户独立生成</span>
|
<span className="status-pill status-pill--neutral">总数 {users.length}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="hero-aside">
|
<div className="hero-aside">
|
||||||
@ -64,11 +62,15 @@ export default async function UsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="panel-card">
|
<section className="panel-card compact-create-panel">
|
||||||
<div className="section-title">
|
<details>
|
||||||
|
<summary className="compact-create-summary">
|
||||||
|
<div>
|
||||||
<h3>新建用户</h3>
|
<h3>新建用户</h3>
|
||||||
</div>
|
</div>
|
||||||
<form action={createUserAction} className="form-grid">
|
<span className="status-pill status-pill--warm">展开</span>
|
||||||
|
</summary>
|
||||||
|
<form action={createUserAction} className="dense-form-grid dense-form-grid--users">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>用户名</span>
|
<span>用户名</span>
|
||||||
<input name="username" required />
|
<input name="username" required />
|
||||||
@ -85,9 +87,9 @@ export default async function UsersPage() {
|
|||||||
<span>总流量上限 GiB</span>
|
<span>总流量上限 GiB</span>
|
||||||
<input name="traffic_limit_gib" type="number" min="0" step="0.1" />
|
<input name="traffic_limit_gib" type="number" min="0" step="0.1" />
|
||||||
</label>
|
</label>
|
||||||
<label className="field full-span">
|
<label className="field dense-field-wide">
|
||||||
<span>备注</span>
|
<span>备注</span>
|
||||||
<textarea name="notes" rows={3} />
|
<textarea name="notes" rows={2} />
|
||||||
</label>
|
</label>
|
||||||
<label className="checkbox">
|
<label className="checkbox">
|
||||||
<input name="enabled" type="checkbox" defaultChecked />
|
<input name="enabled" type="checkbox" defaultChecked />
|
||||||
@ -97,45 +99,63 @@ export default async function UsersPage() {
|
|||||||
创建用户
|
创建用户
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="stack-lg">
|
<section className="panel-card dense-panel">
|
||||||
{users.map((user) => (
|
<div className="section-title">
|
||||||
<article key={user.id} className="panel-card entity-card">
|
<h3>用户列表</h3>
|
||||||
<div className="section-title section-title--top">
|
<span className="status-pill status-pill--neutral">{users.length} 个账户</span>
|
||||||
<div>
|
|
||||||
<h3>{user.username}</h3>
|
|
||||||
<p className="muted mono">auth_id: {user.auth_id}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="align-right stack-tight">
|
<div className="entity-table entity-table--users">
|
||||||
<div className="badge-row badge-row--end">
|
<div className="entity-table__head user-row-grid">
|
||||||
|
<span>用户</span>
|
||||||
|
<span>状态</span>
|
||||||
|
<span>用量</span>
|
||||||
|
<span>剩余额度</span>
|
||||||
|
<span>在线</span>
|
||||||
|
<span>操作</span>
|
||||||
|
</div>
|
||||||
|
{users.map((user) => {
|
||||||
|
const usedBytes = user.used_tx_bytes + user.used_rx_bytes;
|
||||||
|
const subscriptionUrl = user.subscription_token
|
||||||
|
? buildSubscriptionUrl(user.subscription_token, origin)
|
||||||
|
: "暂不可用";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details key={user.id} className="entity-table__item">
|
||||||
|
<summary className="entity-table__summary user-row-grid">
|
||||||
|
<span className="entity-main-cell">
|
||||||
|
<strong>{user.username}</strong>
|
||||||
|
<span className="muted mono break-all">{user.auth_id}</span>
|
||||||
|
</span>
|
||||||
|
<span className="badge-row entity-status-cell">
|
||||||
{getUserPills(user).map((pill) => (
|
{getUserPills(user).map((pill) => (
|
||||||
<span key={pill.label} className={pill.className}>
|
<span key={pill.label} className={pill.className}>
|
||||||
{pill.label}
|
{pill.label}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</span>
|
||||||
<p className="muted">
|
<span className="entity-number-cell">
|
||||||
累计 {formatBytes(user.used_tx_bytes + user.used_rx_bytes)} / 在线{" "}
|
<strong>{formatBytes(usedBytes)}</strong>
|
||||||
{user.online_connections}
|
<span>TX {formatBytes(user.used_tx_bytes)} / RX {formatBytes(user.used_rx_bytes)}</span>
|
||||||
</p>
|
</span>
|
||||||
</div>
|
<span className="entity-number-cell">
|
||||||
</div>
|
|
||||||
<div className="summary-grid">
|
|
||||||
<div className="metric-inline">
|
|
||||||
<span>总用量</span>
|
|
||||||
<strong>{formatBytes(user.used_tx_bytes + user.used_rx_bytes)}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="metric-inline">
|
|
||||||
<span>剩余额度</span>
|
|
||||||
<strong>{describeRemainingTraffic(user)}</strong>
|
<strong>{describeRemainingTraffic(user)}</strong>
|
||||||
</div>
|
<span>{user.traffic_limit_bytes ? "有额度" : "不限量"}</span>
|
||||||
<div className="metric-inline">
|
</span>
|
||||||
<span>在线连接</span>
|
<span className="entity-number-cell">
|
||||||
<strong>{user.online_connections}</strong>
|
<strong>{user.online_connections}</strong>
|
||||||
|
<span>连接</span>
|
||||||
|
</span>
|
||||||
|
<span className="entity-expand-label">编辑</span>
|
||||||
|
</summary>
|
||||||
|
<div className="entity-table__details">
|
||||||
|
<div className="dense-code-line">
|
||||||
|
<span>订阅链接</span>
|
||||||
|
<code>{subscriptionUrl}</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<form action={updateUserAction} className="dense-form-grid dense-form-grid--users">
|
||||||
<form action={updateUserAction} className="form-grid compact">
|
|
||||||
<input name="id" type="hidden" value={user.id} />
|
<input name="id" type="hidden" value={user.id} />
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>用户名</span>
|
<span>用户名</span>
|
||||||
@ -171,9 +191,9 @@ export default async function UsersPage() {
|
|||||||
defaultValue={user.subscription_label || user.username}
|
defaultValue={user.subscription_label || user.username}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field full-span">
|
<label className="field dense-field-wide">
|
||||||
<span>备注</span>
|
<span>备注</span>
|
||||||
<textarea name="notes" rows={3} defaultValue={user.notes} />
|
<textarea name="notes" rows={2} defaultValue={user.notes} />
|
||||||
</label>
|
</label>
|
||||||
<label className="checkbox">
|
<label className="checkbox">
|
||||||
<input
|
<input
|
||||||
@ -187,16 +207,8 @@ export default async function UsersPage() {
|
|||||||
保存修改
|
保存修改
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<div className="instruction-block">
|
<div className="entity-action-strip">
|
||||||
<strong>订阅链接</strong>
|
<form action={resetUserPasswordAction} className="inline-form inline-form--compact">
|
||||||
<p className="muted mono break-all">
|
|
||||||
{user.subscription_token
|
|
||||||
? buildSubscriptionUrl(user.subscription_token, origin)
|
|
||||||
: "暂不可用"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="action-row">
|
|
||||||
<form action={resetUserPasswordAction} className="inline-form">
|
|
||||||
<input name="id" type="hidden" value={user.id} />
|
<input name="id" type="hidden" value={user.id} />
|
||||||
<input name="password" type="password" placeholder="新密码" required />
|
<input name="password" type="password" placeholder="新密码" required />
|
||||||
<button className="ghost-button" type="submit">
|
<button className="ghost-button" type="submit">
|
||||||
@ -212,8 +224,11 @@ export default async function UsersPage() {
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</div>
|
||||||
))}
|
</details>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
711
app/globals.css
711
app/globals.css
@ -1,13 +1,13 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #efe5d4;
|
--background: #f1e7d8;
|
||||||
--foreground: #18222c;
|
--foreground: #121a22;
|
||||||
--panel: rgba(255, 251, 245, 0.76);
|
--panel: rgba(255, 251, 245, 0.9);
|
||||||
--panel-strong: rgba(255, 251, 245, 0.92);
|
--panel-strong: rgba(255, 251, 245, 0.98);
|
||||||
--panel-tint: rgba(255, 244, 230, 0.62);
|
--panel-tint: rgba(255, 244, 230, 0.62);
|
||||||
--border: rgba(24, 34, 44, 0.12);
|
--border: rgba(18, 26, 34, 0.18);
|
||||||
--border-strong: rgba(24, 34, 44, 0.22);
|
--border-strong: rgba(18, 26, 34, 0.32);
|
||||||
--accent: #c96233;
|
--accent: #c96233;
|
||||||
--accent-deep: #7a3116;
|
--accent-deep: #7a3116;
|
||||||
--accent-soft: rgba(201, 98, 51, 0.14);
|
--accent-soft: rgba(201, 98, 51, 0.14);
|
||||||
@ -15,7 +15,7 @@
|
|||||||
--success: #1f7a4f;
|
--success: #1f7a4f;
|
||||||
--danger: #af2d21;
|
--danger: #af2d21;
|
||||||
--warn: #8e5c10;
|
--warn: #8e5c10;
|
||||||
--muted: #5e6670;
|
--muted: #3f4b57;
|
||||||
--shadow: 0 26px 90px rgba(51, 35, 18, 0.12);
|
--shadow: 0 26px 90px rgba(51, 35, 18, 0.12);
|
||||||
--shadow-soft: 0 10px 30px rgba(51, 35, 18, 0.06);
|
--shadow-soft: 0 10px 30px rgba(51, 35, 18, 0.06);
|
||||||
}
|
}
|
||||||
@ -24,7 +24,7 @@
|
|||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: "Avenir Next", "PingFang SC", "Segoe UI", sans-serif;
|
--font-sans: "Avenir Next", "PingFang SC", "Segoe UI", sans-serif;
|
||||||
--font-mono: "IBM Plex Mono", "SFMono-Regular", monospace;
|
--font-mono: ui-monospace, "Cascadia Mono", "SFMono-Regular", "Menlo", "Consolas", monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@ -71,12 +71,14 @@ a {
|
|||||||
|
|
||||||
input,
|
input,
|
||||||
textarea,
|
textarea,
|
||||||
|
select,
|
||||||
button {
|
button {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
textarea {
|
textarea,
|
||||||
|
select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid var(--border-strong);
|
border: 1px solid var(--border-strong);
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
@ -88,7 +90,8 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input:focus,
|
input:focus,
|
||||||
textarea:focus {
|
textarea:focus,
|
||||||
|
select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: rgba(201, 98, 51, 0.55);
|
border-color: rgba(201, 98, 51, 0.55);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
@ -263,7 +266,7 @@ button:disabled {
|
|||||||
|
|
||||||
.login-card--brand h1 {
|
.login-card--brand h1 {
|
||||||
font-size: clamp(2.1rem, 5vw, 3.2rem);
|
font-size: clamp(2.1rem, 5vw, 3.2rem);
|
||||||
letter-spacing: -0.06em;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-form {
|
.login-form {
|
||||||
@ -293,12 +296,6 @@ button:disabled {
|
|||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-note {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.92rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.me-shell {
|
.me-shell {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@ -377,7 +374,6 @@ button:disabled {
|
|||||||
|
|
||||||
.me-grid,
|
.me-grid,
|
||||||
.me-side-stack,
|
.me-side-stack,
|
||||||
.me-tip-grid,
|
|
||||||
.me-metrics-grid,
|
.me-metrics-grid,
|
||||||
.me-hero-copy,
|
.me-hero-copy,
|
||||||
.me-identity-card {
|
.me-identity-card {
|
||||||
@ -435,7 +431,7 @@ button:disabled {
|
|||||||
|
|
||||||
.me-hero-copy h1 {
|
.me-hero-copy h1 {
|
||||||
font-size: clamp(2.2rem, 5vw, 3.4rem);
|
font-size: clamp(2.2rem, 5vw, 3.4rem);
|
||||||
letter-spacing: -0.06em;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.me-identity-card {
|
.me-identity-card {
|
||||||
@ -493,29 +489,6 @@ button:disabled {
|
|||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
.me-tip-grid {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.me-tip-card {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.45rem;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 22px;
|
|
||||||
border: 1px solid rgba(24, 34, 44, 0.08);
|
|
||||||
background: rgba(255, 255, 255, 0.38);
|
|
||||||
}
|
|
||||||
|
|
||||||
.me-tip-card strong {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.me-tip-card p {
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.me-metrics-grid {
|
.me-metrics-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 0.9rem;
|
gap: 0.9rem;
|
||||||
@ -533,12 +506,13 @@ button:disabled {
|
|||||||
.me-metric-card span {
|
.me-metric-card span {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.me-metric-card strong {
|
.me-metric-card strong {
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
letter-spacing: -0.04em;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.me-metric-card--paper {
|
.me-metric-card--paper {
|
||||||
@ -563,15 +537,6 @@ button:disabled {
|
|||||||
align-content: start;
|
align-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.me-note-list {
|
|
||||||
margin: 0;
|
|
||||||
padding-left: 1.15rem;
|
|
||||||
display: grid;
|
|
||||||
gap: 0.75rem;
|
|
||||||
color: var(--muted);
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wide-card {
|
.wide-card {
|
||||||
width: min(56rem, 100%);
|
width: min(56rem, 100%);
|
||||||
}
|
}
|
||||||
@ -618,10 +583,11 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-sans);
|
||||||
font-size: 0.77rem;
|
font-size: 0.77rem;
|
||||||
letter-spacing: 0.18em;
|
letter-spacing: 0;
|
||||||
color: var(--accent-deep);
|
color: var(--accent-deep);
|
||||||
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-lede {
|
.page-lede {
|
||||||
@ -636,7 +602,9 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mono {
|
.mono {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-sans);
|
||||||
|
color: #202b35;
|
||||||
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-note {
|
.sidebar-note {
|
||||||
@ -704,16 +672,11 @@ button:disabled {
|
|||||||
|
|
||||||
.nav-link {
|
.nav-link {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.18rem;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link__label {
|
.nav-link__label {
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link__meta {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.83rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link.is-active {
|
.nav-link.is-active {
|
||||||
@ -736,12 +699,19 @@ button:disabled {
|
|||||||
.primary-button {
|
.primary-button {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #fff8f2;
|
color: #fff8f2;
|
||||||
|
font-weight: 800;
|
||||||
box-shadow: 0 12px 24px rgba(201, 98, 51, 0.24);
|
box-shadow: 0 12px 24px rgba(201, 98, 51, 0.24);
|
||||||
}
|
}
|
||||||
|
|
||||||
.danger-button {
|
.danger-button {
|
||||||
background: rgba(175, 45, 33, 0.14);
|
background: rgba(175, 45, 33, 0.14);
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-button {
|
||||||
|
color: var(--foreground);
|
||||||
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.full-width {
|
.full-width {
|
||||||
@ -778,6 +748,10 @@ button:disabled {
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-row--end {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.section-title--top {
|
.section-title--top {
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
@ -831,11 +805,12 @@ button:disabled {
|
|||||||
|
|
||||||
.metric-card strong {
|
.metric-card strong {
|
||||||
font-size: 1.9rem;
|
font-size: 1.9rem;
|
||||||
letter-spacing: -0.04em;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-card small {
|
.metric-card small {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-card--warm {
|
.metric-card--warm {
|
||||||
@ -898,7 +873,7 @@ button:disabled {
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.4rem 0.75rem;
|
padding: 0.4rem 0.75rem;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -952,6 +927,7 @@ button:disabled {
|
|||||||
.field span {
|
.field span {
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-grid.compact {
|
.form-grid.compact {
|
||||||
@ -999,6 +975,7 @@ button:disabled {
|
|||||||
.stat-tile span {
|
.stat-tile span {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-inline strong,
|
.metric-inline strong,
|
||||||
@ -1026,10 +1003,11 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.feature-kicker {
|
.feature-kicker {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-sans);
|
||||||
color: var(--accent-deep);
|
color: var(--accent-deep);
|
||||||
font-size: 0.76rem;
|
font-size: 0.76rem;
|
||||||
letter-spacing: 0.16em;
|
letter-spacing: 0;
|
||||||
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.break-all {
|
.break-all {
|
||||||
@ -1080,6 +1058,10 @@ button:disabled {
|
|||||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
|
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.proxy-group-builder--main {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
.proxy-group-panel {
|
.proxy-group-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.9rem;
|
gap: 0.9rem;
|
||||||
@ -1237,6 +1219,593 @@ button:disabled {
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.management-page {
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-page .hero-panel {
|
||||||
|
gap: 0.85rem;
|
||||||
|
padding: 0.95rem 1rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-page .hero-copy,
|
||||||
|
.management-page .hero-aside {
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-page .hero-copy h2 {
|
||||||
|
font-size: 1.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-page .page-lede {
|
||||||
|
font-size: 0.94rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-page .stat-tile {
|
||||||
|
min-height: 4.6rem;
|
||||||
|
padding: 0.7rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-page .panel-card {
|
||||||
|
padding: 0.8rem 0.9rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-page .status-pill {
|
||||||
|
padding: 0.24rem 0.52rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-page input,
|
||||||
|
.management-page textarea,
|
||||||
|
.management-page select {
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-page textarea {
|
||||||
|
min-height: 4.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-page .primary-button,
|
||||||
|
.management-page .ghost-button,
|
||||||
|
.management-page .danger-button {
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.55rem 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-create-panel {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-create-panel details {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-create-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.78rem 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-create-summary::-webkit-details-marker,
|
||||||
|
.entity-table__summary::-webkit-details-marker,
|
||||||
|
.config-collapse summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-create-summary h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-create-summary p {
|
||||||
|
margin: 0.18rem 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dense-form-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0.75rem 0.9rem 0.9rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dense-form-grid--users {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dense-form-grid--nodes {
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dense-form-grid .field {
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dense-form-grid .field span {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dense-field-wide {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dense-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-table {
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-table__head,
|
||||||
|
.entity-table__summary {
|
||||||
|
display: grid;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
min-width: 56rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-table__head {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 0.52rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--panel-strong);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-table__item {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-table__item:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-table__summary {
|
||||||
|
padding: 0.62rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-table__summary:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-table__details {
|
||||||
|
min-width: 56rem;
|
||||||
|
padding: 0 0.75rem 0.75rem;
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-row-grid {
|
||||||
|
grid-template-columns: minmax(12rem, 1.25fr) minmax(11rem, 1.1fr) minmax(8.5rem, 0.85fr) minmax(8rem, 0.8fr) minmax(4.5rem, 0.45fr) minmax(4rem, 0.35fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-row-grid {
|
||||||
|
grid-template-columns: minmax(10rem, 1fr) minmax(8rem, 0.75fr) minmax(10rem, 0.95fr) minmax(12rem, 1.15fr) minmax(5.5rem, 0.55fr) minmax(9rem, 0.9fr) minmax(4rem, 0.35fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-row-grid {
|
||||||
|
grid-template-columns: minmax(4.5rem, 0.45fr) minmax(11rem, 1fr) minmax(10rem, 0.95fr) minmax(6.5rem, 0.65fr) minmax(10rem, 1fr) minmax(10rem, 0.8fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-main-cell,
|
||||||
|
.entity-number-cell,
|
||||||
|
.entity-code-cell,
|
||||||
|
.entity-muted-cell {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.12rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-main-cell strong,
|
||||||
|
.entity-number-cell strong {
|
||||||
|
font-size: 0.94rem;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-main-cell span,
|
||||||
|
.entity-number-cell span,
|
||||||
|
.entity-number-cell small,
|
||||||
|
.entity-code-cell small,
|
||||||
|
.entity-muted-cell,
|
||||||
|
.entity-muted-cell small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.79rem;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-code-cell {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #202b35;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-status-cell {
|
||||||
|
gap: 0.28rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-expand-label {
|
||||||
|
justify-self: end;
|
||||||
|
color: var(--accent-deep);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-table__item[open] .entity-expand-label {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dense-code-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
padding-top: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dense-code-line {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.36);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dense-code-line span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dense-code-line code {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #202b35;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-action-strip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.65rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding-top: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-form--compact {
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-form--compact input:not([type="hidden"]) {
|
||||||
|
width: min(16rem, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-collapse {
|
||||||
|
margin-top: 0.65rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-collapse summary {
|
||||||
|
padding: 0.58rem 0.65rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--accent-deep);
|
||||||
|
font-weight: 800;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-collapse pre {
|
||||||
|
overflow: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: #1a1f25;
|
||||||
|
color: #f7eadf;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-collapse .inline-button {
|
||||||
|
margin: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-page {
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-page .hero-panel {
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-page .hero-copy,
|
||||||
|
.dashboard-page .hero-aside {
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-page .hero-copy h2 {
|
||||||
|
font-size: 1.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-page .page-lede {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-page .badge-row {
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-page .status-pill {
|
||||||
|
padding: 0.25rem 0.55rem;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-page .metrics-grid,
|
||||||
|
.dashboard-page .panel-grid {
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-page .metric-card,
|
||||||
|
.dashboard-page .panel-card {
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 0.75rem 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-page .metric-card {
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-height: 5.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-page .metric-card strong {
|
||||||
|
font-size: 1.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-page .metric-card small,
|
||||||
|
.dashboard-page .muted,
|
||||||
|
.dashboard-page .list-row p {
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-page .section-title {
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-page .section-title h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-page .stack-md {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-page .list-row--card {
|
||||||
|
padding: 0.55rem 0;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-page .list-row--card:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-page .list-row p {
|
||||||
|
margin: 0.12rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-page .stat-tile {
|
||||||
|
min-height: 4.4rem;
|
||||||
|
padding: 0.65rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-page select {
|
||||||
|
min-height: 2.25rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.45rem 0.65rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-analytics {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.65rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-filter {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-width: min(100%, 15rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-filter label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-glance-grid,
|
||||||
|
.dashboard-chart-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-glance-grid {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-chart-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-glance-card,
|
||||||
|
.dashboard-chart-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.68rem 0.75rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-glance-card {
|
||||||
|
min-height: 5.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-glance-card span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-glance-card strong {
|
||||||
|
font-size: 1.28rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-glance-card small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-glance-card--warm {
|
||||||
|
background: linear-gradient(180deg, rgba(247, 225, 203, 0.86), rgba(255, 250, 242, 0.64));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-glance-card--ink {
|
||||||
|
background: linear-gradient(180deg, rgba(223, 231, 236, 0.88), rgba(255, 250, 242, 0.66));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-glance-card--sage {
|
||||||
|
background: linear-gradient(180deg, rgba(224, 236, 228, 0.88), rgba(255, 250, 242, 0.66));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-glance-card--paper {
|
||||||
|
background: linear-gradient(180deg, rgba(255, 252, 247, 0.92), rgba(255, 250, 242, 0.68));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-chart-card {
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.56), rgba(255, 255, 255, 0.28));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-chart-card--wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-chart-card__head {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-chart-card h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-note {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-shell,
|
||||||
|
.chart-empty {
|
||||||
|
height: 14rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-shell--wide {
|
||||||
|
height: 17rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-empty {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px dashed var(--border-strong);
|
||||||
|
background: rgba(255, 255, 255, 0.34);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-line-toggles {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-line-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
min-height: 1.7rem;
|
||||||
|
padding: 0.22rem 0.45rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.42);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-line-toggle small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-line-toggle.is-hidden {
|
||||||
|
opacity: 0.45;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-line-toggle__swatch {
|
||||||
|
width: 0.55rem;
|
||||||
|
height: 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
.error-banner,
|
.error-banner,
|
||||||
.error-text {
|
.error-text {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
@ -1269,7 +1838,6 @@ button:disabled {
|
|||||||
.login-layout,
|
.login-layout,
|
||||||
.me-grid,
|
.me-grid,
|
||||||
.me-hero,
|
.me-hero,
|
||||||
.me-tip-grid,
|
|
||||||
.me-metrics-grid,
|
.me-metrics-grid,
|
||||||
.hero-panel,
|
.hero-panel,
|
||||||
.hero-panel--compact,
|
.hero-panel--compact,
|
||||||
@ -1318,6 +1886,15 @@ button:disabled {
|
|||||||
.proxy-group-toolbar {
|
.proxy-group-toolbar {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-toolbar {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-glance-grid,
|
||||||
|
.dashboard-chart-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
|||||||
@ -29,23 +29,15 @@ export default async function LoginPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="login-shell login-shell--immersive">
|
<main className="login-shell login-shell--immersive">
|
||||||
<div aria-hidden="true" className="login-backdrop">
|
|
||||||
<span className="login-orb login-orb--one" />
|
|
||||||
<span className="login-orb login-orb--two" />
|
|
||||||
<span className="login-orb login-orb--three" />
|
|
||||||
</div>
|
|
||||||
<section className="login-stage">
|
<section className="login-stage">
|
||||||
<article className="login-card login-card--form login-card--brand">
|
<article className="login-card login-card--form login-card--brand">
|
||||||
<div className="section-title">
|
<div className="section-title">
|
||||||
<div>
|
<div>
|
||||||
<span className="eyebrow">FEIEPROXY ACCESS</span>
|
<span className="eyebrow">安全登录</span>
|
||||||
<h1>鹅梯 FeieProxy</h1>
|
<h1>鹅梯 FeieProxy</h1>
|
||||||
</div>
|
</div>
|
||||||
<span className="status-pill status-pill--neutral">家宽节点</span>
|
<span className="status-pill status-pill--neutral">家宽节点</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="page-lede">
|
|
||||||
统一管理多节点鉴权、流量同步与 Clash 订阅。管理员进入控制台,普通用户进入自助页查看剩余流量并生成订阅链接。
|
|
||||||
</p>
|
|
||||||
<form action={loginAction} className="stack-lg login-form">
|
<form action={loginAction} className="stack-lg login-form">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>用户名</span>
|
<span>用户名</span>
|
||||||
@ -59,10 +51,9 @@ export default async function LoginPage({
|
|||||||
<p className="error-banner">用户名或密码错误。</p>
|
<p className="error-banner">用户名或密码错误。</p>
|
||||||
) : null}
|
) : null}
|
||||||
<button className="primary-button" type="submit">
|
<button className="primary-button" type="submit">
|
||||||
登录控制台
|
登录
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<p className="login-note">管理员与普通用户共用入口,系统会自动跳转到对应面板。</p>
|
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { formatLocalDateTime } from "@/lib/datetime";
|
||||||
import { getRequestOrigin } from "@/lib/request-origin";
|
import { getRequestOrigin } from "@/lib/request-origin";
|
||||||
import { buildSubscriptionUrl, describeRemainingTraffic } from "@/lib/subscription";
|
import { buildSubscriptionUrl, describeRemainingTraffic } from "@/lib/subscription";
|
||||||
import { requireUserSession } from "@/lib/session";
|
import { requireUserSession } from "@/lib/session";
|
||||||
@ -31,20 +32,11 @@ export default async function MePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="me-shell">
|
<main className="me-shell">
|
||||||
<div aria-hidden="true" className="me-backdrop">
|
|
||||||
<span className="me-glow me-glow--warm" />
|
|
||||||
<span className="me-glow me-glow--cool" />
|
|
||||||
<span className="me-glow me-glow--light" />
|
|
||||||
</div>
|
|
||||||
<section className="me-stage">
|
<section className="me-stage">
|
||||||
<article className="me-panel me-hero">
|
<article className="me-panel me-hero">
|
||||||
<div className="me-hero-copy">
|
<div className="me-hero-copy">
|
||||||
<span className="eyebrow">FEIEPROXY SELF SERVICE</span>
|
<span className="eyebrow">账户中心</span>
|
||||||
<h1>{user.username}</h1>
|
<h1>{user.username}</h1>
|
||||||
<p className="page-lede">
|
|
||||||
鹅梯 FeieProxy 用户面板。这里可以查看配额状态、获取订阅链接,并通过
|
|
||||||
Clash URL Scheme 一键导入当前配置。
|
|
||||||
</p>
|
|
||||||
<div className="badge-row">
|
<div className="badge-row">
|
||||||
<span
|
<span
|
||||||
className={`status-pill ${
|
className={`status-pill ${
|
||||||
@ -54,7 +46,7 @@ export default async function MePage() {
|
|||||||
{user.enabled ? "账号已启用" : "账号已禁用"}
|
{user.enabled ? "账号已启用" : "账号已禁用"}
|
||||||
</span>
|
</span>
|
||||||
<span className="status-pill status-pill--neutral">
|
<span className="status-pill status-pill--neutral">
|
||||||
{user.expires_at ?? "长期有效"}
|
{formatLocalDateTime(user.expires_at, "长期有效")}
|
||||||
</span>
|
</span>
|
||||||
<span className="status-pill status-pill--warm">
|
<span className="status-pill status-pill--warm">
|
||||||
{describeRemainingTraffic(user)}
|
{describeRemainingTraffic(user)}
|
||||||
@ -62,7 +54,7 @@ export default async function MePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="me-identity-card">
|
<div className="me-identity-card">
|
||||||
<span className="feature-kicker">ACCOUNT SNAPSHOT</span>
|
<span className="feature-kicker">用户 ID</span>
|
||||||
<strong className="mono break-all">{user.auth_id}</strong>
|
<strong className="mono break-all">{user.auth_id}</strong>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
订阅名称:{user.subscription_label || user.username}
|
订阅名称:{user.subscription_label || user.username}
|
||||||
@ -74,18 +66,15 @@ export default async function MePage() {
|
|||||||
<article className="me-panel me-subscription-card">
|
<article className="me-panel me-subscription-card">
|
||||||
<div className="section-title section-title--top">
|
<div className="section-title section-title--top">
|
||||||
<div>
|
<div>
|
||||||
<span className="feature-kicker">SUBSCRIPTION</span>
|
<span className="feature-kicker">订阅</span>
|
||||||
<h2>一键导入配置</h2>
|
<h2>订阅</h2>
|
||||||
</div>
|
</div>
|
||||||
<span className="status-pill status-pill--ink">Clash / Mihomo</span>
|
<span className="status-pill status-pill--ink">Clash / Mihomo</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="muted">
|
|
||||||
推荐直接通过按钮唤起支持 `clash://` 的客户端导入。若浏览器拦截了自定义协议,允许一次即可。
|
|
||||||
</p>
|
|
||||||
<div className="me-action-row">
|
<div className="me-action-row">
|
||||||
{clashImportUrl ? (
|
{clashImportUrl ? (
|
||||||
<a className="primary-button me-action-button" href={clashImportUrl}>
|
<a className="primary-button me-action-button" href={clashImportUrl}>
|
||||||
一键导入 Clash
|
一键导入
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="status-pill status-pill--off">订阅暂不可用</span>
|
<span className="status-pill status-pill--off">订阅暂不可用</span>
|
||||||
@ -102,21 +91,9 @@ export default async function MePage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="me-link-card">
|
<div className="me-link-card">
|
||||||
<span className="feature-kicker">SUBSCRIPTION URL</span>
|
<span className="feature-kicker">订阅链接</span>
|
||||||
<p className="mono break-all">{subscriptionUrl ?? "暂不可用"}</p>
|
<p className="mono break-all">{subscriptionUrl ?? "暂不可用"}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="me-tip-grid">
|
|
||||||
<div className="me-tip-card">
|
|
||||||
<span className="feature-kicker">TIP 01</span>
|
|
||||||
<strong>客户端未唤起时</strong>
|
|
||||||
<p className="muted">确认已安装支持 `clash://` 的客户端,并允许浏览器打开外部应用。</p>
|
|
||||||
</div>
|
|
||||||
<div className="me-tip-card">
|
|
||||||
<span className="feature-kicker">TIP 02</span>
|
|
||||||
<strong>手动导入也可用</strong>
|
|
||||||
<p className="muted">复制上方订阅链接,在 Clash / Mihomo 客户端中粘贴导入即可。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<div className="me-side-stack">
|
<div className="me-side-stack">
|
||||||
@ -145,13 +122,8 @@ export default async function MePage() {
|
|||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article className="me-panel me-support-card">
|
<article className="me-panel me-support-card">
|
||||||
<span className="feature-kicker">ACCESS</span>
|
<span className="feature-kicker">账户</span>
|
||||||
<h2>使用说明</h2>
|
<h2>账户</h2>
|
||||||
<ul className="me-note-list">
|
|
||||||
<li>域名入口适合网页登录与获取订阅。</li>
|
|
||||||
<li>现有节点鉴权仍可继续使用原来的 `IP:3000` 直连地址。</li>
|
|
||||||
<li>如果订阅内容更新,重新点击导入按钮即可刷新客户端配置。</li>
|
|
||||||
</ul>
|
|
||||||
<form action="/logout" method="post">
|
<form action="/logout" method="post">
|
||||||
<button className="ghost-button me-action-button me-action-button--full" type="submit">
|
<button className="ghost-button me-action-button me-action-button--full" type="submit">
|
||||||
退出登录
|
退出登录
|
||||||
|
|||||||
79
bun.lock
79
bun.lock
@ -10,6 +10,7 @@
|
|||||||
"next": "16.2.3",
|
"next": "16.2.3",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -187,8 +188,14 @@
|
|||||||
|
|
||||||
"@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="],
|
"@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="],
|
||||||
|
|
||||||
|
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
|
||||||
|
|
||||||
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
|
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
|
||||||
|
|
||||||
|
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||||
|
|
||||||
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
||||||
|
|
||||||
"@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="],
|
"@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="],
|
||||||
@ -225,6 +232,24 @@
|
|||||||
|
|
||||||
"@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="],
|
"@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="],
|
||||||
|
|
||||||
|
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
|
||||||
|
|
||||||
|
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
|
||||||
|
|
||||||
|
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
|
||||||
|
|
||||||
|
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
|
||||||
|
|
||||||
|
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
|
||||||
|
|
||||||
|
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
|
||||||
|
|
||||||
|
"@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="],
|
||||||
|
|
||||||
|
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
|
||||||
|
|
||||||
|
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
|
||||||
|
|
||||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||||
|
|
||||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||||
@ -237,6 +262,8 @@
|
|||||||
|
|
||||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||||
|
|
||||||
|
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
||||||
|
|
||||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.58.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/type-utils": "8.58.2", "@typescript-eslint/utils": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.58.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw=="],
|
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.58.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/type-utils": "8.58.2", "@typescript-eslint/utils": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.58.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw=="],
|
||||||
|
|
||||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.58.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg=="],
|
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.58.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg=="],
|
||||||
@ -371,6 +398,8 @@
|
|||||||
|
|
||||||
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||||
|
|
||||||
|
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|
||||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||||
|
|
||||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
@ -383,6 +412,28 @@
|
|||||||
|
|
||||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
|
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
|
||||||
|
|
||||||
|
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
|
||||||
|
|
||||||
|
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
|
||||||
|
|
||||||
|
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
|
||||||
|
|
||||||
|
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
|
||||||
|
|
||||||
|
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
|
||||||
|
|
||||||
|
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
|
||||||
|
|
||||||
|
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
|
||||||
|
|
||||||
|
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
|
||||||
|
|
||||||
|
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
|
||||||
|
|
||||||
|
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
|
||||||
|
|
||||||
"damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="],
|
"damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="],
|
||||||
|
|
||||||
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
|
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
|
||||||
@ -393,6 +444,8 @@
|
|||||||
|
|
||||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
|
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
|
||||||
|
|
||||||
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
|
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
|
||||||
|
|
||||||
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
|
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
|
||||||
@ -433,6 +486,8 @@
|
|||||||
|
|
||||||
"es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="],
|
"es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="],
|
||||||
|
|
||||||
|
"es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="],
|
||||||
|
|
||||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||||
|
|
||||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||||
@ -469,6 +524,8 @@
|
|||||||
|
|
||||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||||
|
|
||||||
|
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
|
||||||
|
|
||||||
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
|
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
|
||||||
|
|
||||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||||
@ -551,6 +608,8 @@
|
|||||||
|
|
||||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||||
|
|
||||||
|
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
|
||||||
|
|
||||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||||
|
|
||||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||||
@ -561,6 +620,8 @@
|
|||||||
|
|
||||||
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
|
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
|
||||||
|
|
||||||
|
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||||
|
|
||||||
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
|
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
|
||||||
|
|
||||||
"is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="],
|
"is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="],
|
||||||
@ -773,12 +834,22 @@
|
|||||||
|
|
||||||
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||||
|
|
||||||
|
"react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
|
||||||
|
|
||||||
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||||
|
|
||||||
|
"recharts": ["recharts@3.8.1", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg=="],
|
||||||
|
|
||||||
|
"redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
|
||||||
|
|
||||||
|
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
|
||||||
|
|
||||||
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
||||||
|
|
||||||
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
|
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
|
||||||
|
|
||||||
|
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
|
||||||
|
|
||||||
"resolve": ["resolve@2.0.0-next.6", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA=="],
|
"resolve": ["resolve@2.0.0-next.6", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA=="],
|
||||||
|
|
||||||
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||||
@ -863,6 +934,8 @@
|
|||||||
|
|
||||||
"tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
|
"tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
|
||||||
|
|
||||||
|
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||||
|
|
||||||
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||||
|
|
||||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||||
@ -899,8 +972,12 @@
|
|||||||
|
|
||||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||||
|
|
||||||
|
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||||
|
|
||||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
|
|
||||||
|
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
|
||||||
|
|
||||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|
||||||
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
|
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
|
||||||
@ -929,6 +1006,8 @@
|
|||||||
|
|
||||||
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
||||||
|
|
||||||
|
"@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
|
||||||
|
|||||||
20
lib/datetime.ts
Normal file
20
lib/datetime.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
export function formatLocalDateTime(value: string | null | undefined, fallback = "无") {
|
||||||
|
if (!value) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
@ -221,6 +221,7 @@ async function initializeDb(db: Database.Database) {
|
|||||||
settings.run("clash_template", defaultTemplate, now);
|
settings.run("clash_template", defaultTemplate, now);
|
||||||
settings.run("extra_proxy_groups", "", now);
|
settings.run("extra_proxy_groups", "", now);
|
||||||
settings.run("profile_name", "FeieProxy", now);
|
settings.run("profile_name", "FeieProxy", now);
|
||||||
|
settings.run("main_proxy_group_member_slugs_json", "[]", now);
|
||||||
settings.run("proxy_groups_json", "[]", now);
|
settings.run("proxy_groups_json", "[]", now);
|
||||||
|
|
||||||
const existingAdminUser = db
|
const existingAdminUser = db
|
||||||
|
|||||||
329
lib/store.ts
329
lib/store.ts
@ -59,6 +59,14 @@ export type ProxyGroupConfig = {
|
|||||||
memberSlugs: string[];
|
memberSlugs: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SubscriptionSettings = {
|
||||||
|
template: string;
|
||||||
|
extraProxyGroups: string;
|
||||||
|
profileName: string;
|
||||||
|
mainProxyGroupMemberSlugs: string[];
|
||||||
|
proxyGroups: ProxyGroupConfig[];
|
||||||
|
};
|
||||||
|
|
||||||
type AuditRecordInput = {
|
type AuditRecordInput = {
|
||||||
nodeId?: number | null;
|
nodeId?: number | null;
|
||||||
userId?: number | null;
|
userId?: number | null;
|
||||||
@ -478,6 +486,93 @@ export async function listAudits(limit = 100) {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DashboardTrafficTrendRow = {
|
||||||
|
bucket_start: string;
|
||||||
|
node_id: number;
|
||||||
|
auth_id: string;
|
||||||
|
tx_bytes: number;
|
||||||
|
rx_bytes: number;
|
||||||
|
total_bytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DashboardLoadTrendRow = {
|
||||||
|
bucket_start: string;
|
||||||
|
node_id: number;
|
||||||
|
online_users: number;
|
||||||
|
stream_count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DashboardFailureTrendRow = {
|
||||||
|
bucket_start: string;
|
||||||
|
node_id: number;
|
||||||
|
failed_count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DashboardTopConsumerRow = {
|
||||||
|
node_id: number;
|
||||||
|
auth_id: string;
|
||||||
|
username: string;
|
||||||
|
tx_bytes: number;
|
||||||
|
rx_bytes: number;
|
||||||
|
total_bytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DashboardTrafficUserOption = {
|
||||||
|
auth_id: string;
|
||||||
|
username: string;
|
||||||
|
total_bytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createUtcBucketSeries(options: {
|
||||||
|
size: number;
|
||||||
|
unit: "hour" | "day";
|
||||||
|
}) {
|
||||||
|
const end = new Date();
|
||||||
|
|
||||||
|
if (options.unit === "hour") {
|
||||||
|
end.setUTCMinutes(0, 0, 0);
|
||||||
|
} else {
|
||||||
|
end.setUTCHours(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from({ length: options.size }, (_, index) => {
|
||||||
|
const bucket = new Date(end);
|
||||||
|
const offset = options.size - index - 1;
|
||||||
|
|
||||||
|
if (options.unit === "hour") {
|
||||||
|
bucket.setUTCHours(bucket.getUTCHours() - offset);
|
||||||
|
} else {
|
||||||
|
bucket.setUTCDate(bucket.getUTCDate() - offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bucket.toISOString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUtcBucketKey(value: string, unit: "hour" | "day") {
|
||||||
|
const date = new Date(value);
|
||||||
|
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unit === "hour") {
|
||||||
|
date.setUTCMinutes(0, 0, 0);
|
||||||
|
} else {
|
||||||
|
date.setUTCHours(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSeriesMapKey(bucketStart: string, nodeId: number) {
|
||||||
|
return `${bucketStart}:${nodeId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTrafficSeriesMapKey(bucketStart: string, nodeId: number, authId: string) {
|
||||||
|
return `${bucketStart}|${nodeId}|${authId}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getDashboardMetrics() {
|
export async function getDashboardMetrics() {
|
||||||
const db = await ensureDb();
|
const db = await ensureDb();
|
||||||
|
|
||||||
@ -625,6 +720,215 @@ export async function getDashboardMetrics() {
|
|||||||
node_name: string;
|
node_name: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
const hourlyBuckets = createUtcBucketSeries({ size: 24, unit: "hour" });
|
||||||
|
const dailyBuckets = createUtcBucketSeries({ size: 14, unit: "day" });
|
||||||
|
const weeklyBuckets = createUtcBucketSeries({ size: 7, unit: "day" });
|
||||||
|
const trackedNodeIds = nodeHealth.map((node) => node.id);
|
||||||
|
const trafficCutoff = hourlyBuckets[0] ?? new Date().toISOString();
|
||||||
|
const failureCutoff = dailyBuckets[0] ?? new Date().toISOString();
|
||||||
|
const consumerCutoff = weeklyBuckets[0] ?? new Date().toISOString();
|
||||||
|
|
||||||
|
const trafficRows = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
node_id,
|
||||||
|
auth_id,
|
||||||
|
window_ended_at,
|
||||||
|
tx_bytes,
|
||||||
|
rx_bytes
|
||||||
|
FROM traffic_ledgers
|
||||||
|
WHERE window_ended_at >= ?
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.all(trafficCutoff) as Array<{
|
||||||
|
node_id: number;
|
||||||
|
auth_id: string;
|
||||||
|
window_ended_at: string;
|
||||||
|
tx_bytes: number;
|
||||||
|
rx_bytes: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const trafficBucketSet = new Set(hourlyBuckets);
|
||||||
|
const trafficMap = new Map<string, { tx_bytes: number; rx_bytes: number }>();
|
||||||
|
|
||||||
|
for (const row of trafficRows) {
|
||||||
|
const bucketStart = getUtcBucketKey(row.window_ended_at, "hour");
|
||||||
|
|
||||||
|
if (!bucketStart || !trafficBucketSet.has(bucketStart)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = getTrafficSeriesMapKey(bucketStart, row.node_id, row.auth_id);
|
||||||
|
const existing = trafficMap.get(key) ?? { tx_bytes: 0, rx_bytes: 0 };
|
||||||
|
existing.tx_bytes += row.tx_bytes;
|
||||||
|
existing.rx_bytes += row.rx_bytes;
|
||||||
|
trafficMap.set(key, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const trafficTrend: DashboardTrafficTrendRow[] = [];
|
||||||
|
|
||||||
|
for (const [key, counters] of trafficMap.entries()) {
|
||||||
|
const [bucketStart, nodeId, authId] = key.split("|");
|
||||||
|
|
||||||
|
trafficTrend.push({
|
||||||
|
bucket_start: bucketStart,
|
||||||
|
node_id: Number(nodeId),
|
||||||
|
auth_id: authId,
|
||||||
|
tx_bytes: counters.tx_bytes,
|
||||||
|
rx_bytes: counters.rx_bytes,
|
||||||
|
total_bytes: counters.tx_bytes + counters.rx_bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadRows = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
node_id,
|
||||||
|
finished_at,
|
||||||
|
online_users,
|
||||||
|
stream_count
|
||||||
|
FROM node_sync_records
|
||||||
|
WHERE ok = 1 AND finished_at >= ?
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.all(trafficCutoff) as Array<{
|
||||||
|
node_id: number;
|
||||||
|
finished_at: string;
|
||||||
|
online_users: number;
|
||||||
|
stream_count: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const loadBucketSet = new Set(hourlyBuckets);
|
||||||
|
const loadMap = new Map<string, { online_users: number; stream_count: number }>();
|
||||||
|
|
||||||
|
for (const row of loadRows) {
|
||||||
|
const bucketStart = getUtcBucketKey(row.finished_at, "hour");
|
||||||
|
|
||||||
|
if (!bucketStart || !loadBucketSet.has(bucketStart)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = getSeriesMapKey(bucketStart, row.node_id);
|
||||||
|
const existing = loadMap.get(key) ?? { online_users: 0, stream_count: 0 };
|
||||||
|
existing.online_users = Math.max(existing.online_users, row.online_users);
|
||||||
|
existing.stream_count = Math.max(existing.stream_count, row.stream_count);
|
||||||
|
loadMap.set(key, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadTrend: DashboardLoadTrendRow[] = [];
|
||||||
|
|
||||||
|
for (const bucketStart of hourlyBuckets) {
|
||||||
|
for (const nodeId of trackedNodeIds) {
|
||||||
|
const snapshot = loadMap.get(getSeriesMapKey(bucketStart, nodeId)) ?? {
|
||||||
|
online_users: 0,
|
||||||
|
stream_count: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
loadTrend.push({
|
||||||
|
bucket_start: bucketStart,
|
||||||
|
node_id: nodeId,
|
||||||
|
online_users: snapshot.online_users,
|
||||||
|
stream_count: snapshot.stream_count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const failureRows = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
COALESCE(node_id, 0) AS node_id,
|
||||||
|
created_at
|
||||||
|
FROM auth_audits
|
||||||
|
WHERE ok = 0 AND created_at >= ?
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.all(failureCutoff) as Array<{
|
||||||
|
node_id: number;
|
||||||
|
created_at: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const failureBucketSet = new Set(dailyBuckets);
|
||||||
|
const failureMap = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const row of failureRows) {
|
||||||
|
const bucketStart = getUtcBucketKey(row.created_at, "day");
|
||||||
|
|
||||||
|
if (!bucketStart || !failureBucketSet.has(bucketStart)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = getSeriesMapKey(bucketStart, row.node_id);
|
||||||
|
failureMap.set(key, (failureMap.get(key) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const failureNodeIds = trackedNodeIds.includes(0) ? trackedNodeIds : [0, ...trackedNodeIds];
|
||||||
|
const failureTrend: DashboardFailureTrendRow[] = [];
|
||||||
|
|
||||||
|
for (const bucketStart of dailyBuckets) {
|
||||||
|
for (const nodeId of failureNodeIds) {
|
||||||
|
failureTrend.push({
|
||||||
|
bucket_start: bucketStart,
|
||||||
|
node_id: nodeId,
|
||||||
|
failed_count: failureMap.get(getSeriesMapKey(bucketStart, nodeId)) ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const topConsumerRows = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
l.node_id,
|
||||||
|
l.auth_id,
|
||||||
|
COALESCE(u.username, l.auth_id) AS username,
|
||||||
|
SUM(l.tx_bytes) AS tx_bytes,
|
||||||
|
SUM(l.rx_bytes) AS rx_bytes,
|
||||||
|
SUM(l.tx_bytes + l.rx_bytes) AS total_bytes
|
||||||
|
FROM traffic_ledgers l
|
||||||
|
LEFT JOIN hy2_users u ON u.id = l.user_id
|
||||||
|
WHERE l.window_ended_at >= ?
|
||||||
|
GROUP BY l.node_id, l.auth_id, username
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.all(consumerCutoff) as DashboardTopConsumerRow[];
|
||||||
|
|
||||||
|
const trafficUserOptions = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
auth_id,
|
||||||
|
username,
|
||||||
|
SUM(total_bytes) AS total_bytes
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
u.auth_id,
|
||||||
|
u.username,
|
||||||
|
COALESCE(SUM(l.tx_bytes + l.rx_bytes), 0) AS total_bytes
|
||||||
|
FROM hy2_users u
|
||||||
|
LEFT JOIN traffic_ledgers l
|
||||||
|
ON l.auth_id = u.auth_id AND l.window_ended_at >= ?
|
||||||
|
GROUP BY u.auth_id, u.username
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
l.auth_id,
|
||||||
|
l.auth_id AS username,
|
||||||
|
SUM(l.tx_bytes + l.rx_bytes) AS total_bytes
|
||||||
|
FROM traffic_ledgers l
|
||||||
|
LEFT JOIN hy2_users u ON u.auth_id = l.auth_id
|
||||||
|
WHERE u.id IS NULL AND l.window_ended_at >= ?
|
||||||
|
GROUP BY l.auth_id
|
||||||
|
)
|
||||||
|
GROUP BY auth_id, username
|
||||||
|
ORDER BY total_bytes DESC, username ASC
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.all(consumerCutoff, consumerCutoff) as DashboardTrafficUserOption[];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totals,
|
totals,
|
||||||
topUsers,
|
topUsers,
|
||||||
@ -632,6 +936,12 @@ export async function getDashboardMetrics() {
|
|||||||
recentFailures,
|
recentFailures,
|
||||||
recentUsage,
|
recentUsage,
|
||||||
syncErrors,
|
syncErrors,
|
||||||
|
trafficTrend,
|
||||||
|
trafficBuckets: hourlyBuckets,
|
||||||
|
trafficUserOptions,
|
||||||
|
loadTrend,
|
||||||
|
failureTrend,
|
||||||
|
topConsumerRows,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -760,8 +1070,11 @@ export async function getSubscriptionSettings() {
|
|||||||
template: map.get("clash_template") ?? "",
|
template: map.get("clash_template") ?? "",
|
||||||
extraProxyGroups: map.get("extra_proxy_groups") ?? "",
|
extraProxyGroups: map.get("extra_proxy_groups") ?? "",
|
||||||
profileName: map.get("profile_name") ?? "FeieProxy",
|
profileName: map.get("profile_name") ?? "FeieProxy",
|
||||||
|
mainProxyGroupMemberSlugs: JSON.parse(
|
||||||
|
map.get("main_proxy_group_member_slugs_json") ?? "[]",
|
||||||
|
) as string[],
|
||||||
proxyGroups: JSON.parse(map.get("proxy_groups_json") ?? "[]") as ProxyGroupConfig[],
|
proxyGroups: JSON.parse(map.get("proxy_groups_json") ?? "[]") as ProxyGroupConfig[],
|
||||||
};
|
} satisfies SubscriptionSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveSubscriptionSettings(input: {
|
export async function saveSubscriptionSettings(input: {
|
||||||
@ -799,6 +1112,20 @@ export async function saveProxyGroups(proxyGroups: ProxyGroupConfig[]) {
|
|||||||
).run("proxy_groups_json", JSON.stringify(proxyGroups), now);
|
).run("proxy_groups_json", JSON.stringify(proxyGroups), now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function saveMainProxyGroupMemberSlugs(memberSlugs: string[]) {
|
||||||
|
const db = await ensureDb();
|
||||||
|
const now = dbNow();
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO app_settings (key, value, updated_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET
|
||||||
|
value = excluded.value,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`,
|
||||||
|
).run("main_proxy_group_member_slugs_json", JSON.stringify(memberSlugs), now);
|
||||||
|
}
|
||||||
|
|
||||||
export async function listSubscriptionNodes() {
|
export async function listSubscriptionNodes() {
|
||||||
const db = await ensureDb();
|
const db = await ensureDb();
|
||||||
return db
|
return db
|
||||||
|
|||||||
@ -14,6 +14,7 @@ type ClashSettings = {
|
|||||||
template: string;
|
template: string;
|
||||||
extraProxyGroups: string;
|
extraProxyGroups: string;
|
||||||
profileName: string;
|
profileName: string;
|
||||||
|
mainProxyGroupMemberSlugs: string[];
|
||||||
proxyGroups: ProxyGroupConfig[];
|
proxyGroups: ProxyGroupConfig[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -63,6 +64,34 @@ function renderGroupEntry(name: string, proxyNames: string[]) {
|
|||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function orderNodesBySlugs(nodes: Hy2NodeRow[], orderedSlugs: string[]) {
|
||||||
|
if (orderedSlugs.length === 0) {
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodesBySlug = new Map(nodes.map((node) => [node.slug, node]));
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const orderedNodes: Hy2NodeRow[] = [];
|
||||||
|
|
||||||
|
for (const slug of orderedSlugs) {
|
||||||
|
const node = nodesBySlug.get(slug);
|
||||||
|
if (!node || seen.has(slug)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
orderedNodes.push(node);
|
||||||
|
seen.add(slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (!seen.has(node.slug)) {
|
||||||
|
orderedNodes.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return orderedNodes;
|
||||||
|
}
|
||||||
|
|
||||||
export function encodeRFC5987ValueChars(value: string) {
|
export function encodeRFC5987ValueChars(value: string) {
|
||||||
return encodeURIComponent(value)
|
return encodeURIComponent(value)
|
||||||
.replace(/['()]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)
|
.replace(/['()]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)
|
||||||
@ -141,11 +170,15 @@ export function buildSubscriptionConfig(input: {
|
|||||||
const eligibleNodes = input.nodes.filter(
|
const eligibleNodes = input.nodes.filter(
|
||||||
(node) => node.enabled && node.server_host && node.server_port > 0,
|
(node) => node.enabled && node.server_host && node.server_port > 0,
|
||||||
);
|
);
|
||||||
|
const orderedEligibleNodes = orderNodesBySlugs(
|
||||||
|
eligibleNodes,
|
||||||
|
input.settings.mainProxyGroupMemberSlugs,
|
||||||
|
);
|
||||||
const nodeNameBySlug = new Map(
|
const nodeNameBySlug = new Map(
|
||||||
eligibleNodes.map((node) => [node.slug, node.client_name || node.name]),
|
eligibleNodes.map((node) => [node.slug, node.client_name || node.name]),
|
||||||
);
|
);
|
||||||
const proxyNames = eligibleNodes.map((node) => node.client_name || node.name);
|
const proxyNames = orderedEligibleNodes.map((node) => node.client_name || node.name);
|
||||||
const proxiesBlock = eligibleNodes.map((node) => renderProxy(node, authString)).join("\n");
|
const proxiesBlock = orderedEligibleNodes.map((node) => renderProxy(node, authString)).join("\n");
|
||||||
const defaultTemplate = input.settings.template.trimEnd() || readDefaultTemplate();
|
const defaultTemplate = input.settings.template.trimEnd() || readDefaultTemplate();
|
||||||
const renderedGroups = [
|
const renderedGroups = [
|
||||||
renderGroupEntry(input.settings.profileName || "FeieProxy", proxyNames),
|
renderGroupEntry(input.settings.profileName || "FeieProxy", proxyNames),
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
"next": "16.2.3",
|
"next": "16.2.3",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user