Merge remote-tracking branch 'origin/main'

# Conflicts:
#	AGENTS.md
#	backend/docs/docs.go
#	backend/docs/swagger.json
#	backend/docs/swagger.yaml
#	frontend/components/providers/title-updater.tsx
#	frontend/messages/fragments/admin.en.json
#	frontend/messages/fragments/admin.zh-CN.json
#	frontend/proxy.ts
This commit is contained in:
ryan
2026-09-03 09:16:22 +08:00
1065 changed files with 271858 additions and 9600 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* OpenFlare 统一 API 响应信封解析。
* 与后端 internal/common/response.Response 及 axios api-client 约定一致。
* 与后端 internal/shared/response.Response 及 axios api-client 约定一致。
*/
export interface ApiEnvelope<T = unknown> {
+2 -2
View File
@@ -1,6 +1,6 @@
/**
* Cap PoW (Proof-of-Work) 人机验证前端实现
* 与后端 internal/util/cap 算法完全对应
* 与后端 pkg/cap 算法完全对应
*
* 求解在 Web Worker 中执行,不阻塞主线程 UI。
*/
@@ -24,7 +24,7 @@ export interface RedeemResponse {
// ——— Worker 代码(内联 Blob,避免独立文件的打包配置问题)———
//
// 算法与 Go 后端 internal/util/cap/cap.go + prng.go 完全对应:
// 算法与 Go 后端 pkg/cap/cap.go + prng.go 完全对应:
// · FNV-1a 32-bit (Math.imul 保证 32-bit 截断)
// · xorshift32 PRNG → hex 字符串
// · SubtleCrypto SHA-256 校验答案
+195
View File
@@ -0,0 +1,195 @@
import type { LucideIcon } from 'lucide-react';
import {
FileText,
Gauge,
GitBranch,
Globe,
LayoutDashboard,
Route,
ScrollText,
Server,
ShieldCheck,
} from 'lucide-react';
export interface OpenFlareNavItem {
titleKey: string;
url: string;
icon: LucideIcon;
/** 子页面在侧栏中仍高亮父级菜单项 */
childUrls?: string[];
}
export interface OpenFlareNavSubItem {
titleKey: string;
url: string;
childUrls?: string[];
}
export interface OpenFlareNavGroup {
titleKey: string;
icon: LucideIcon;
items: OpenFlareNavSubItem[];
}
export type OpenFlareSidebarNavEntry =
| ({ kind: 'item' } & OpenFlareNavItem)
| ({ kind: 'group' } & OpenFlareNavGroup);
/** 安全性折叠组 */
export const openflareSecurityNavGroup: OpenFlareNavGroup = {
titleKey: 'security',
icon: ShieldCheck,
items: [
{ titleKey: 'waf', url: '/waf' },
{ titleKey: 'ipGroups', url: '/ip-groups' },
{ titleKey: 'rateLimits', url: '/rate-limits' },
],
};
/** 网站管理折叠组 */
export const openflareWebsiteNavGroup: OpenFlareNavGroup = {
titleKey: 'websites',
icon: Globe,
items: [
{ titleKey: 'websites', url: '/websites', childUrls: ['/websites/detail'] },
{ titleKey: 'certificates', url: '/certificates' },
{ titleKey: 'dnsAccounts', url: '/dns-accounts' },
{
titleKey: 'cloudflare',
url: '/cloudflare',
childUrls: ['/cloudflare/settings'],
},
{ titleKey: 'origins', url: '/origins', childUrls: ['/origins/detail'] },
{
titleKey: 'responses',
url: '/responses',
childUrls: [
'/responses/error-page/edit',
'/responses/error-page/preview',
'/responses/offline/edit',
'/responses/offline/preview',
],
},
],
};
/**
* OpenFlare 侧栏导航顺序(单一配置源)。
* 调整菜单顺序或折叠组位置时,只需修改此数组。
*/
export const openflareSidebarNav: OpenFlareSidebarNavEntry[] = [
{ kind: 'item', titleKey: 'dashboard', url: '/', icon: LayoutDashboard },
{
kind: 'item',
titleKey: 'nodes',
url: '/nodes',
icon: Server,
childUrls: ['/nodes/detail'],
},
{
kind: 'item',
titleKey: 'proxyRoutes',
url: '/proxy-routes',
icon: Route,
childUrls: ['/proxy-routes/detail'],
},
{ kind: 'group', ...openflareWebsiteNavGroup },
{ kind: 'group', ...openflareSecurityNavGroup },
{
kind: 'item',
titleKey: 'pages',
url: '/pages',
icon: FileText,
childUrls: ['/pages/detail'],
},
{
kind: 'item',
titleKey: 'configVersions',
url: '/config-versions',
icon: GitBranch,
},
{
kind: 'item',
titleKey: 'accessLogs',
url: '/access-logs',
icon: ScrollText,
},
{ kind: 'item', titleKey: 'performance', url: '/performance', icon: Gauge },
];
/** 扁平菜单项(供路由判断等逻辑复用) */
export const openflareNavItems: OpenFlareNavItem[] = openflareSidebarNav
.filter(
(entry): entry is { kind: 'item' } & OpenFlareNavItem =>
entry.kind === 'item',
)
.map((entry) => {
const { kind, ...item } = entry;
void kind;
return item;
});
/** 网站模块页内二级导航 */
export const openflareWebsiteSubNav = [
{ titleKey: 'websites', url: '/websites' },
{ titleKey: 'certificates', url: '/certificates' },
{ titleKey: 'dnsAccounts', url: '/dns-accounts' },
{ titleKey: 'cloudflare', url: '/cloudflare' },
{ titleKey: 'responses', url: '/responses' },
] as const;
const nonConsoleRoutePrefixes = [
'/admin',
'/settings',
'/files',
'/home',
'/login',
'/register',
'/docs',
];
export function matchesNavPath(
pathname: string,
url: string,
childUrls?: string[],
): boolean {
if (url === '/') {
return pathname === '/';
}
if (pathname === url || pathname.startsWith(`${url}/`)) {
return true;
}
return (childUrls ?? []).some(
(childUrl) => pathname === childUrl || pathname.startsWith(`${childUrl}/`),
);
}
export function isNavGroupActive(
pathname: string,
group: OpenFlareNavGroup,
): boolean {
return group.items.some((item) =>
matchesNavPath(pathname, item.url, item.childUrls),
);
}
/** 判断当前路径是否属于 OpenFlare 业务控制台 */
export function isOpenFlareConsoleRoute(pathname: string): boolean {
if (
nonConsoleRoutePrefixes.some(
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
)
) {
return false;
}
return openflareSidebarNav.some((entry) => {
if (entry.kind === 'group') {
return isNavGroupActive(pathname, entry);
}
return matchesNavPath(pathname, entry.url, entry.childUrls);
});
}
@@ -0,0 +1,28 @@
import {
DEFAULT_ORIGIN_ERROR_PAGE_TEMPLATE_ID,
getOriginErrorPageTemplate,
} from '@/lib/openflare/origin-error-page-templates';
/**
* 预览/回退用的默认 HTML:与内置模板目录中的 minimalist 对齐。
* 边缘侧空配置时的默认页仍由 Go DefaultOriginErrorPageHTML 提供。
*/
export const DEFAULT_ORIGIN_ERROR_PAGE_HTML =
getOriginErrorPageTemplate(DEFAULT_ORIGIN_ERROR_PAGE_TEMPLATE_ID)?.html ?? '';
/** Max HTML size in bytes (aligned with backend 256 KiB). */
export const ORIGIN_ERROR_PAGE_HTML_MAX_BYTES = 256 * 1024;
export function effectiveOriginErrorPageHTML(html: string): string {
return html.trim() === '' ? DEFAULT_ORIGIN_ERROR_PAGE_HTML : html;
}
export function previewOriginErrorPageHTML(
html: string,
status = '502',
host = 'example.com',
): string {
return effectiveOriginErrorPageHTML(html)
.replaceAll('{{status}}', status)
.replaceAll('{{host}}', host);
}
@@ -0,0 +1,170 @@
/** 内置离线页模板:baohaus */
const html = `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>离线页 | OpenFlare</title>
<style>
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: #f4f3ef;
color: #121212;
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 48px 24px;
-webkit-font-smoothing: antialiased;
}
.bauhaus-card {
width: 100%;
max-width: 580px;
background: #ffffff;
border: 3px solid #121212;
box-shadow: 8px 8px 0px #121212;
display: flex;
flex-direction: column;
overflow: hidden;
}
.card-accent-bar {
height: 12px;
display: flex;
}
.accent-red { flex: 2; background-color: #e63946; }
.accent-blue { flex: 3; background-color: #1d3557; }
.accent-yellow { flex: 1; background-color: #f1faee; }
.card-body {
padding: 40px 32px;
display: flex;
flex-direction: column;
gap: 24px;
}
.tag {
align-self: flex-start;
background-color: #121212;
color: #ffffff;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 4px 10px;
}
.title {
font-size: 28px;
font-weight: 800;
line-height: 1.2;
letter-spacing: -0.02em;
}
.message {
font-size: 15px;
line-height: 1.6;
color: #4a4a4a;
}
.contact-box {
border: 2px solid #121212;
background-color: #f9f8f3;
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.contact-line {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
font-weight: 600;
}
.contact-line a {
color: #e63946;
text-decoration: none;
font-weight: 700;
}
.contact-line a:hover {
text-decoration: underline;
}
.card-footer {
border-top: 2px solid #121212;
padding: 16px 32px;
background-color: #faf9f6;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.05em;
}
.brand {
display: flex;
align-items: center;
gap: 6px;
}
.brand-icon {
width: 16px;
height: 16px;
fill: currentColor;
}
</style>
</head>
<body>
<div class="bauhaus-card">
<div class="card-accent-bar">
<div class="accent-red"></div>
<div class="accent-blue"></div>
<div class="accent-yellow"></div>
</div>
<div class="card-body">
<div class="tag">OFFLINE FALLBACK</div>
<h1 class="title">离线联系站长</h1>
<p class="message">
源站网络临时不可达。离线 Service Worker 已拦截请求,您可以使用下方联系方式取得支持。
</p>
<div class="contact-box">
<div class="contact-line">
<span>站长邮箱</span>
<a href="mailto:admin@example.com">admin@example.com</a>
</div>
<div class="contact-line">
<span>服务监控</span>
<a href="https://status.example.com" target="_blank" rel="noopener">status.example.com</a>
</div>
</div>
</div>
<div class="card-footer">
<div class="brand">
<svg class="brand-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M13 2L3 14H12L11 22L21 10H12L13 2Z" />
</svg>
<span>OpenFlare Service Worker</span>
</div>
<span>OFFLINE MODE</span>
</div>
</div>
</body>
</html>
`;
export default html;
@@ -0,0 +1,49 @@
/**
* 内置离线页 HTML 模板目录。
*/
import baohausHtml from './baohaus';
import lineStyleHtml from './line-style';
import minimalistHtml from './minimalist';
export type OfflinePageTemplate = {
/** 稳定 ID */
id: string;
/** 下拉展示名称 */
name: string;
/** 完整 HTML */
html: string;
};
export const OFFLINE_PAGE_TEMPLATES: OfflinePageTemplate[] = [
{
id: 'minimalist',
name: '极简白底',
html: minimalistHtml,
},
{
id: 'line-style',
name: '线框拓扑',
html: lineStyleHtml,
},
{
id: 'baohaus',
name: '包豪斯',
html: baohausHtml,
},
];
/** 默认预制模板 */
export const DEFAULT_OFFLINE_PAGE_TEMPLATE_ID = 'minimalist';
export const DEFAULT_OFFLINE_PAGE_HTML =
getOfflinePageTemplate(DEFAULT_OFFLINE_PAGE_TEMPLATE_ID)?.html ?? '';
export function effectiveOfflinePageHTML(html: string): string {
return html.trim() === '' ? DEFAULT_OFFLINE_PAGE_HTML : html;
}
export function getOfflinePageTemplate(
id: string,
): OfflinePageTemplate | undefined {
return OFFLINE_PAGE_TEMPLATES.find((item) => item.id === id);
}
@@ -0,0 +1,193 @@
/** 内置离线页模板:line-style */
const html = `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>离线页 | OpenFlare</title>
<style>
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--color-bg: #fcfcfc;
--color-line: #e0e0e0;
--color-line-hover: #1a1a1a;
--color-text-main: #1a1a1a;
--color-text-sub: #666666;
--color-text-muted: #999999;
--color-accent: #d4d4d4;
--font-geo: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
body {
font-family: var(--font-geo);
background-color: var(--color-bg);
background-image: radial-gradient(circle, var(--color-accent) 0.8px, transparent 0.8px);
background-size: 24px 24px;
color: var(--color-text-main);
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 48px 24px;
-webkit-font-smoothing: antialiased;
letter-spacing: 0.01em;
}
.main-container {
width: 100%;
max-width: 640px;
display: flex;
flex-direction: column;
gap: 32px;
}
.line-box {
border: 1px solid var(--color-line);
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(4px);
padding: 32px;
transition: all var(--transition-fast);
position: relative;
}
.line-box:hover {
border-color: var(--color-line-hover);
box-shadow: 0 0 0 1px var(--color-line-hover);
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--color-text-sub);
margin-bottom: 12px;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: #f59e0b;
}
.title {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 12px;
}
.description {
font-size: 14px;
line-height: 1.6;
color: var(--color-text-sub);
margin-bottom: 24px;
}
.contact-list {
display: flex;
flex-direction: column;
gap: 12px;
border-top: 1px dashed var(--color-line);
padding-top: 20px;
}
.contact-row {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
}
.contact-label {
color: var(--color-text-muted);
font-size: 12px;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.contact-value a {
color: var(--color-text-main);
font-weight: 600;
text-decoration: none;
border-bottom: 1px solid var(--color-line);
transition: border-color var(--transition-fast);
}
.contact-value a:hover {
border-color: var(--color-text-main);
}
.footer-bar {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 11px;
color: var(--color-text-muted);
letter-spacing: 0.05em;
}
.brand {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
color: var(--color-text-main);
}
.brand-icon {
width: 14px;
height: 14px;
fill: currentColor;
}
</style>
</head>
<body>
<div class="main-container">
<div class="line-box">
<div class="status-badge">
<span class="status-dot"></span>
SERVICE WORKER OFFLINE CACHE
</div>
<h1 class="title">网络中断或源站不可达</h1>
<p class="description">
系统已自动启用 Service Worker 兜底保护。如需紧急联系站长,请通过以下渠道:
</p>
<div class="contact-list">
<div class="contact-row">
<span class="contact-label">Email Support</span>
<span class="contact-value"><a href="mailto:admin@example.com">admin@example.com</a></span>
</div>
<div class="contact-row">
<span class="contact-label">Status Page</span>
<span class="contact-value"><a href="https://status.example.com" target="_blank" rel="noopener">status.example.com</a></span>
</div>
</div>
</div>
<div class="footer-bar">
<div class="brand">
<svg class="brand-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M13 2L3 14H12L11 22L21 10H12L13 2Z" />
</svg>
<span>OpenFlare Edge</span>
</div>
<span>FALLBACK ACTIVE</span>
</div>
</div>
</body>
</html>
`;
export default html;
@@ -0,0 +1,128 @@
/** 内置离线页模板:minimalist(默认) */
const html = `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>离线页 | OpenFlare</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: #ffffff;
color: #333333;
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
padding: 48px 24px;
}
.container {
max-width: 560px;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
}
.badge {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 9999px;
background-color: #f3f4f6;
color: #4b5563;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.05em;
}
.title {
font-size: 32px;
font-weight: 700;
color: #111827;
line-height: 1.25;
}
.description {
font-size: 15px;
line-height: 1.6;
color: #6b7280;
max-width: 480px;
}
.contact-card {
width: 100%;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
text-align: left;
}
.contact-item {
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
color: #374151;
}
.contact-item svg {
width: 18px;
height: 18px;
color: #6b7280;
flex-shrink: 0;
}
.contact-item a {
color: #2563eb;
text-decoration: none;
}
.contact-item a:hover {
text-decoration: underline;
}
.footer {
margin-top: 32px;
display: flex;
align-items: center;
gap: 8px;
color: #9ca3af;
font-size: 13px;
}
.brand-icon {
width: 20px;
height: 20px;
fill: currentColor;
}
</style>
</head>
<body>
<div class="container">
<div class="badge">OFFLINE CONTACT</div>
<h1 class="title">站点暂不可用,联系站长</h1>
<p class="description">
当前无法连接至源站。页面已开启 Service Worker 离线保护,您可以通过以下方式与站长取得联系。
</p>
<div class="contact-card">
<div class="contact-item">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path></svg>
<span>联系邮箱:<a href="mailto:admin@example.com">admin@example.com</a></span>
</div>
<div class="contact-item">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
<span>服务状态页:<a href="https://status.example.com" target="_blank" rel="noopener">status.example.com</a></span>
</div>
</div>
<div class="footer">
<svg class="brand-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M13 2L3 14H12L11 22L21 10H12L13 2Z" />
</svg>
<span>OpenFlare Protection</span>
</div>
</div>
</body>
</html>
`;
export default html;
@@ -0,0 +1,293 @@
/** 内置错误页模板:baohaus(包豪斯风格) */
const html = `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{status}} | OpenFlare</title>
<style>
:root {
--color-red: #E53935;
--color-yellow: #FDD835;
--color-blue: #1E88E5;
--color-black: #212121;
--color-white: #FAFAFA;
--color-grey: #9E9E9E;
--font-primary: 'Futura', 'Century Gothic', 'Tw Cen MT', sans-serif;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--color-white);
color: var(--color-black);
font-family: var(--font-primary);
min-height: 100vh;
width: 100%;
display: flex;
flex-direction: column;
position: relative;
overflow-x: hidden;
}
body::before {
content: "";
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: radial-gradient(var(--color-grey) 1px, transparent 1px);
background-size: 20px 20px;
opacity: 0.15;
z-index: -1;
pointer-events: none;
}
.layout-container {
display: grid;
grid-template-columns: 1fr 1.618fr;
grid-template-rows: 1fr 1fr;
min-height: 100vh;
width: 100%;
}
.zone-left {
grid-column: 1 / 2;
grid-row: 1 / 3;
border-right: 4px solid var(--color-black);
border-bottom: 4px solid var(--color-black);
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: var(--color-white);
padding: 2rem;
z-index: 2;
}
.decor-circle {
position: absolute;
width: 40vh;
height: 40vh;
background-color: var(--color-yellow);
border-radius: 50%;
top: 15%;
left: 10%;
z-index: 1;
}
.error-number {
font-size: clamp(6rem, 12vw, 14rem);
font-weight: 700;
line-height: 0.9;
letter-spacing: -0.04em;
color: var(--color-black);
position: relative;
z-index: 2;
}
.brand-footer {
position: absolute;
bottom: 2rem;
left: 2rem;
display: flex;
align-items: center;
gap: 10px;
z-index: 3;
}
.brand-icon {
width: 24px;
height: 24px;
fill: var(--color-black);
}
.brand-text {
font-size: 0.85rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.15em;
}
.zone-top-right {
grid-column: 2 / 3;
grid-row: 1 / 2;
border-bottom: 4px solid var(--color-black);
background-color: var(--color-white);
padding: 4rem;
display: flex;
flex-direction: column;
justify-content: center;
z-index: 2;
}
.error-title {
font-size: clamp(1.5rem, 3vw, 2.5rem);
text-transform: uppercase;
font-weight: 700;
margin-bottom: 1.5rem;
letter-spacing: 0.05em;
}
.error-desc {
font-size: 1.1rem;
line-height: 1.6;
max-width: 450px;
color: var(--color-black);
}
.meta-data {
margin-top: 2rem;
font-family: monospace;
font-size: 0.8rem;
color: var(--color-grey);
border-top: 1px solid var(--color-grey);
padding-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
word-break: break-all;
}
.zone-bottom-right {
grid-column: 2 / 3;
grid-row: 2 / 3;
background-color: var(--color-white);
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.geo-line {
position: absolute;
width: 100%;
height: 3px;
background-color: var(--color-black);
top: 50%;
left: 0;
}
.geo-triangle {
position: absolute;
right: 15%;
bottom: 15%;
width: 0;
height: 0;
border-left: 80px solid transparent;
border-right: 80px solid transparent;
border-bottom: 140px solid var(--color-blue);
}
.geo-rect {
position: absolute;
top: 40%;
left: 20%;
width: 150px;
height: 30px;
background-color: var(--color-red);
transform: rotate(-45deg);
}
@media (max-width: 900px) {
.layout-container {
display: flex;
flex-direction: column;
}
.zone-left {
border-right: none;
border-bottom: 4px solid var(--color-black);
min-height: 50vh;
padding-top: 4rem;
}
.decor-circle {
width: 30vh;
height: 30vh;
top: 10%;
}
.zone-top-right {
border-bottom: 4px solid var(--color-black);
padding: 2rem;
}
.zone-bottom-right {
min-height: 30vh;
}
.geo-triangle {
border-left-width: 50px;
border-right-width: 50px;
border-bottom-width: 90px;
right: 10%;
}
.geo-rect {
width: 100px;
height: 20px;
left: 15%;
}
}
</style>
</head>
<body>
<div class="layout-container">
<div class="zone-left">
<div class="decor-circle"></div>
<div class="error-number" aria-label="HTTP status">{{status}}</div>
<div class="brand-footer">
<svg class="brand-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
</svg>
<span class="brand-text">OpenFlare</span>
</div>
</div>
<div class="zone-top-right">
<h1 class="error-title">Origin Unavailable</h1>
<p class="error-desc">
The server received an invalid response from the upstream server.
<br><br>
<strong>Form follows function:</strong> The connection path is broken. Please verify the origin server status.
</p>
<div class="meta-data">
<div>ERR_CODE: {{status}}</div>
<div>HOST: {{host}}</div>
<div>PROXY: OPENFLARE_EDGE</div>
<div id="timestamp">--:--:-- UTC</div>
</div>
</div>
<div class="zone-bottom-right">
<div class="geo-line"></div>
<div class="geo-rect"></div>
<div class="geo-triangle"></div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const tsElement = document.getElementById('timestamp');
if (tsElement) {
const now = new Date();
tsElement.textContent = now.toISOString().split('T')[1].split('.')[0] + ' UTC';
}
});
</script>
</body>
</html>
`;
export default html;
@@ -0,0 +1,46 @@
/**
* 内置源站错误页 HTML 模板目录。
* 每个模板对应本目录下一个独立 `.ts` 模块(export default 完整 HTML 字符串)。
* 新增模板:新增 `*.ts` 并在下方数组注册。
*
* 模板须使用占位符 {{status}} / {{host}},禁止写死状态码。
*/
import baohausHtml from './baohaus';
import lineStyleHtml from './line-style';
import minimalistHtml from './minimalist';
export type OriginErrorPageTemplate = {
/** 稳定 ID,与文件名一致 */
id: string;
/** 下拉展示名称 */
name: string;
/** 完整 HTML,含 {{status}} / {{host}} */
html: string;
};
export const ORIGIN_ERROR_PAGE_TEMPLATES: OriginErrorPageTemplate[] = [
{
id: 'minimalist',
name: '极简白底',
html: minimalistHtml,
},
{
id: 'line-style',
name: '线框拓扑',
html: lineStyleHtml,
},
{
id: 'baohaus',
name: '包豪斯',
html: baohausHtml,
},
];
/** 默认预制模板(前端预览空 HTML 回退 & 加载模板默认选中) */
export const DEFAULT_ORIGIN_ERROR_PAGE_TEMPLATE_ID = 'minimalist';
export function getOriginErrorPageTemplate(
id: string,
): OriginErrorPageTemplate | undefined {
return ORIGIN_ERROR_PAGE_TEMPLATES.find((item) => item.id === id);
}
@@ -0,0 +1,309 @@
/** 内置错误页模板:line-style */
const html = `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{status}} | OpenFlare</title>
<style>
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--color-bg: #fcfcfc;
--color-line: #e0e0e0;
--color-line-hover: #1a1a1a;
--color-text-main: #1a1a1a;
--color-text-sub: #666666;
--color-text-muted: #999999;
--color-accent: #d4d4d4;
--font-geo: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
body {
font-family: var(--font-geo);
background-color: var(--color-bg);
background-image: radial-gradient(circle, var(--color-accent) 0.8px, transparent 0.8px);
background-size: 24px 24px;
color: var(--color-text-main);
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 48px 24px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
letter-spacing: 0.01em;
}
.main-container {
width: 100%;
max-width: 640px;
display: flex;
flex-direction: column;
gap: 48px;
}
.line-box {
border: 1px solid var(--color-line);
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(4px);
padding: 32px;
transition: all var(--transition-fast);
position: relative;
}
.line-box:hover {
border-color: var(--color-line-hover);
box-shadow: 0 0 0 1px var(--color-line-hover);
}
.line-box:active {
transform: scale(0.995);
transition-duration: 100ms;
}
.error-header {
text-align: center;
}
.error-code {
font-size: 64px;
font-weight: 700;
line-height: 1;
letter-spacing: -0.03em;
color: var(--color-text-main);
margin-bottom: 12px;
}
.error-label {
font-size: 12px;
font-weight: 500;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--color-text-sub);
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.error-label::before,
.error-label::after {
content: '';
width: 24px;
height: 1px;
background-color: var(--color-line);
}
.topology-wrapper {
width: 100%;
overflow: hidden;
}
.topology-svg {
width: 100%;
height: 140px;
display: block;
}
.node-circle {
fill: var(--color-bg);
stroke: var(--color-text-main);
stroke-width: 1.5;
transition: all var(--transition-fast);
}
.node-circle.error {
stroke: var(--color-text-muted);
stroke-dasharray: 3 3;
}
.flow-line {
stroke: var(--color-accent);
stroke-width: 1.5;
fill: none;
transition: stroke var(--transition-fast);
}
.flow-line.error {
stroke: var(--color-text-muted);
stroke-dasharray: 4 4;
}
.node-text {
font-family: var(--font-geo);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
fill: var(--color-text-sub);
text-anchor: middle;
}
.status-text {
font-family: var(--font-geo);
font-size: 9px;
font-weight: 500;
letter-spacing: 0.05em;
fill: var(--color-text-muted);
text-anchor: middle;
}
.status-text.error {
fill: #b0b0b0;
}
.description {
text-align: center;
}
.desc-text {
font-size: 18px;
line-height: 1.6;
color: var(--color-text-sub);
font-weight: 400;
max-width: 480px;
margin: 0 auto 24px;
}
.meta-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
border-top: 1px solid var(--color-line);
padding-top: 24px;
text-align: left;
}
.meta-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.meta-key {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-text-muted);
}
.meta-val {
font-size: 13px;
font-family: 'SF Mono', 'Fira Code', monospace, var(--font-geo);
color: var(--color-text-main);
font-weight: 500;
word-break: break-all;
}
.footer-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--color-text-muted);
font-size: 13px;
font-weight: 500;
letter-spacing: 0.05em;
transition: color var(--transition-fast);
cursor: default;
}
.footer-brand:hover {
color: var(--color-text-main);
}
.brand-icon {
width: 20px;
height: 20px;
stroke: currentColor;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
fill: none;
}
@media (max-width: 480px) {
body {
padding: 32px 16px;
}
.error-code {
font-size: 48px;
}
.line-box {
padding: 24px 16px;
}
.meta-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="main-container">
<div class="line-box error-header">
<div class="error-code" aria-label="HTTP status">{{status}}</div>
<div class="error-label">Origin Unavailable</div>
</div>
<div class="line-box topology-wrapper">
<svg class="topology-svg" viewBox="0 0 600 140" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 1 L 8 5 L 0 9 z" fill="#d4d4d4" />
</marker>
<marker id="arrow-error" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 1 L 8 5 L 0 9 z" fill="#999999" />
</marker>
</defs>
<line x1="135" y1="70" x2="265" y2="70" class="flow-line" marker-end="url(#arrow)" />
<line x1="335" y1="70" x2="465" y2="70" class="flow-line error" marker-end="url(#arrow-error)" />
<circle cx="110" cy="70" r="20" class="node-circle" />
<text x="110" y="74" class="node-text" style="font-size: 8px; fill: #1a1a1a;">CLI</text>
<text x="110" y="115" class="node-text">CLIENT</text>
<text x="110" y="130" class="status-text">REQ_SENT</text>
<circle cx="300" cy="70" r="20" class="node-circle" />
<path d="M296 70 L304 64 L301 70 L305 70 L296 76 L299 70 Z" fill="none" stroke="#1a1a1a" stroke-width="1" stroke-linejoin="round"/>
<text x="300" y="115" class="node-text">OPENFLARE</text>
<text x="300" y="130" class="status-text">EDGE_PROXY</text>
<circle cx="490" cy="70" r="20" class="node-circle error" />
<line x1="484" y1="64" x2="496" y2="76" stroke="#999" stroke-width="1.5" stroke-linecap="round"/>
<line x1="496" y1="64" x2="484" y2="76" stroke="#999" stroke-width="1.5" stroke-linecap="round"/>
<text x="490" y="115" class="node-text" style="fill: #999;">ORIGIN</text>
<text x="490" y="130" class="status-text error">UNAVAILABLE</text>
</svg>
</div>
<div class="footer-brand">
<svg class="brand-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
</svg>
<span>OpenFlare</span>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const tsEl = document.getElementById('timestamp');
if (tsEl) {
const now = new Date();
tsEl.textContent = now.toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
}
});
</script>
</body>
</html>
`;
export default html;
@@ -0,0 +1,109 @@
/** 内置错误页模板:minimalist(默认) */
const html = `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{status}} | OpenFlare</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
background-color: #ffffff;
color: #333333;
height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
padding: 48px 24px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.container {
max-width: 600px;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
}
.error-code {
font-size: 48px;
font-weight: 700;
color: #333333;
line-height: 1.2;
letter-spacing: -0.02em;
}
.error-description {
font-size: 20px;
line-height: 1.6;
color: #666666;
max-width: 480px;
}
.host {
font-size: 14px;
color: #999999;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
word-break: break-all;
}
.footer {
margin-top: 48px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: #999999;
font-size: 14px;
font-weight: 500;
}
.brand-icon {
width: 24px;
height: 24px;
fill: currentColor;
display: block;
}
@media (max-width: 480px) {
.error-code {
font-size: 36px;
}
.error-description {
font-size: 18px;
}
}
</style>
</head>
<body>
<div class="container">
<h1 class="error-code" aria-label="HTTP status">{{status}}</h1>
<p class="error-description">
The upstream server is unreachable. Please try again later or contact the site administrator if the problem persists.
</p>
<p class="host">{{host}}</p>
<div class="footer">
<svg class="brand-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M13 2L3 14H12L11 22L21 10H12L13 2Z" />
</svg>
<span>OpenFlare</span>
</div>
</div>
</body>
</html>
`;
export default html;
+100
View File
@@ -0,0 +1,100 @@
/** Matches pkg/render/openresty StatusCodeMin / StatusCodeMax. */
export const STATUS_CODE_MIN = 400;
export const STATUS_CODE_MAX = 599;
export const DEFAULT_ORIGIN_ERROR_PAGE_STATUS_TAGS = ['500-599'] as const;
/**
* Parse a single tag such as "502" or "500-599".
* Bounds must fall within 400–599 inclusive (aligned with Go ParseStatusCodeTag).
*/
export function parseStatusCodeTag(tag: string): {
lo: number;
hi: number;
} {
const trimmed = tag.trim();
if (!trimmed) {
throw new Error('状态码标签不能为空');
}
let lo: number;
let hi: number;
const dash = trimmed.indexOf('-');
if (dash >= 0) {
lo = Number.parseInt(trimmed.slice(0, dash), 10);
hi = Number.parseInt(trimmed.slice(dash + 1), 10);
if (Number.isNaN(lo) || Number.isNaN(hi)) {
throw new Error(`无效状态码区间: ${trimmed}`);
}
} else {
lo = Number.parseInt(trimmed, 10);
if (Number.isNaN(lo)) {
throw new Error(`无效状态码: ${trimmed}`);
}
hi = lo;
}
if (lo > hi) {
throw new Error(`状态码区间左右端点反序: ${trimmed}`);
}
if (lo < STATUS_CODE_MIN || hi > STATUS_CODE_MAX) {
throw new Error(
`状态码须在 ${STATUS_CODE_MIN}–${STATUS_CODE_MAX}: ${trimmed}`,
);
}
return { lo, hi };
}
/** Expand tags into a sorted unique list of integers. */
export function expandStatusCodeTags(tags: string[]): number[] {
const set = new Set<number>();
for (const tag of tags) {
const { lo, hi } = parseStatusCodeTag(tag);
for (let code = lo; code <= hi; code += 1) {
set.add(code);
}
}
return Array.from(set).sort((a, b) => a - b);
}
/** Soft validator for TagsInput: returns error message or null if ok. */
export function validateStatusCodeTagMessage(tag: string): string | null {
try {
parseStatusCodeTag(tag);
return null;
} catch (error) {
return error instanceof Error ? error.message : '无效状态码标签';
}
}
/** Validate a full tag list before save (must be non-empty and expand cleanly). */
export function validateStatusCodeTags(tags: string[]): void {
if (tags.length === 0) {
throw new Error('请至少添加一个状态码标签');
}
const codes = expandStatusCodeTags(tags);
if (codes.length === 0) {
throw new Error('状态码展开结果为空');
}
}
export function parseStatusCodeTagsJSON(raw: string | undefined): string[] {
if (!raw || !raw.trim()) {
return [...DEFAULT_ORIGIN_ERROR_PAGE_STATUS_TAGS];
}
try {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
return [...DEFAULT_ORIGIN_ERROR_PAGE_STATUS_TAGS];
}
const tags = parsed
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim())
.filter(Boolean);
return tags.length > 0 ? tags : [...DEFAULT_ORIGIN_ERROR_PAGE_STATUS_TAGS];
} catch {
return [...DEFAULT_ORIGIN_ERROR_PAGE_STATUS_TAGS];
}
}
+6
View File
@@ -212,6 +212,10 @@ export interface TaskExecution {
export interface ListTaskExecutionsRequest {
status?: TaskExecutionStatus;
task_type?: string;
/** Prefix match on stored asynq task type (ignored when task_type / task_types is set). */
task_type_prefix?: string;
/** Comma-separated exact asynq task types for IN filter (ignored when task_type is set). */
task_types?: string;
page?: number;
page_size?: number;
}
@@ -413,6 +417,8 @@ export interface LogDatabaseStatus {
retention_days: Record<string, number>;
/** 当前主库的合法迁移目标 */
available_targets: string[];
/** ClickHouse 运行指标(仅主库为 ClickHouse 时) */
clickhouse?: unknown;
}
/**
+1 -1
View File
@@ -4,8 +4,8 @@ import type {
CreateUserRequest,
ListUsersRequest,
ListUsersResponse,
UpdateUserStatusRequest,
UpdateUserRequest,
UpdateUserStatusRequest,
} from './types';
export class AdminUserService extends BaseService {
+1 -1
View File
@@ -90,7 +90,7 @@ function getRequestKey(config: {
*/
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// Browser must use same-origin relative URLs so Session Cookie hits Next rewrite.
// Browser must use same-origin relative URLs so Session Cookie hits Next rewrite (3010→3000).
if (typeof window !== 'undefined') {
config.baseURL = getApiBaseUrl();
}
+7 -7
View File
@@ -1,24 +1,19 @@
/**
* API 配置
*
* 浏览器默认走同源相对路径(`/api/...`),由 `next.config` rewrites 反代到后端,
* 避免 dev 下跨域。仅当设置了 `NEXT_PUBLIC_WAVELET_BACKEND_URL` 时浏览器才直连后端
*(静态导出 embed / WebSocket 等场景)。
*/
/**
* 获取 API 基础 URL
* @returns API 基础 URL;浏览器侧空字符串表示同源相对路径
* @returns API 基础 URL
*/
export function getApiBaseUrl(): string {
if (typeof window === 'undefined') {
return (
process.env.WAVELET_BACKEND_URL ||
process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL ||
'http://localhost:8000'
'http://localhost:3000'
);
}
// 空字符串 → axios 请求 `/api/...`,经 Next rewrites 到后端,无 CORS 问题
return process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || '';
}
@@ -30,6 +25,11 @@ export const apiConfig = {
baseURL: getApiBaseUrl(),
/** 超时时间(毫秒) */
timeout: 15000,
/**
* 文件上传超时(毫秒)。
* Pages 部署包上限 100 MiB,需覆盖上传 + 服务端解压校验与入库时间。
*/
uploadTimeout: 10 * 60 * 1000,
/** 携带凭证 */
withCredentials: true,
} as const;
@@ -0,0 +1,22 @@
/**
* Serialize query params for Gin-compatible repeated array keys.
* Axios default uses hosts[]= which Gin QueryArray("hosts") ignores.
*/
export function serializeSearchParams(
params?: Record<string, unknown>,
): string {
if (!params) return '';
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null || value === '') continue;
if (Array.isArray(value)) {
for (const item of value) {
if (item === undefined || item === null || item === '') continue;
search.append(key, String(item));
}
continue;
}
search.append(key, String(value));
}
return search.toString();
}
@@ -61,7 +61,7 @@ export class DbManageService extends BaseService {
const disposition = response.headers['content-disposition'] as
| string
| undefined;
let filename = 'wavelet_export';
let filename = 'openflare_export';
if (disposition) {
const match = disposition.match(/filename="?([^";]+)"?/);
if (match) filename = match[1];
+87 -19
View File
@@ -23,13 +23,27 @@ import {
import { AuthService } from './auth';
import { ConfigService } from './config';
import { DbManageService } from './db-manage';
import {
AdminMessageGatewayService,
UserMessageGatewayService,
} from './message-gateway';
import { PushService } from './push';
import { AdminUploadService, UploadService } from './upload';
import { UserService } from './user';
import {
AccessLogService,
ApplyLogService,
ConfigVersionService,
DashboardService,
DnsAccountService,
NodeService,
OptionService,
OriginService,
PagesService,
ProxyRouteService,
StatusService,
TlsCertificateService,
UptimeKumaService,
WafService,
ZoneDomainService,
ZoneService,
} from './openflare';
const services = {
auth: AuthService,
@@ -47,8 +61,22 @@ const services = {
adminUpload: AdminUploadService,
dbManage: DbManageService,
push: PushService,
adminMessageGateway: AdminMessageGatewayService,
userMessageGateway: UserMessageGatewayService,
openflareNode: NodeService,
openflareProxyRoute: ProxyRouteService,
openflareConfigVersion: ConfigVersionService,
openflareApplyLog: ApplyLogService,
openflareDashboard: DashboardService,
openflareWaf: WafService,
openflareZone: ZoneService,
openflareZoneDomain: ZoneDomainService,
openflareTls: TlsCertificateService,
openflareDns: DnsAccountService,
openflarePages: PagesService,
openflareOrigin: OriginService,
openflareAccessLog: AccessLogService,
openflareOption: OptionService,
openflareUptimeKuma: UptimeKumaService,
openflareStatus: StatusService,
} as const;
export default services;
@@ -173,19 +201,6 @@ export type {
} from './db-manage';
export { PushService } from './push';
export {
AdminMessageGatewayService,
UserMessageGatewayService,
} from './message-gateway';
export type {
MessageChannel,
MessageChannelDefinition,
CreateMessageChannelRequest,
UpdateMessageChannelRequest,
PublicMessageChannel,
MessageBinding,
BindMessageChannelRequest,
} from './message-gateway';
export type {
PushEvent,
PushHistory,
@@ -195,3 +210,56 @@ export type {
UpdatePushEventRequest,
TestPushRequest,
} from './push';
// ==================== OpenFlare 业务服务导出 ====================
export {
OpenFlareBaseService,
NodeService,
ProxyRouteService,
ConfigVersionService,
ApplyLogService,
DashboardService,
WafService,
TlsCertificateService,
DnsAccountService,
PagesService,
OriginService,
AccessLogService,
OptionService,
StatusService,
UptimeKumaService,
} from './openflare';
export type {
NodeItem,
ProxyRouteItem,
ProxyRouteZoneDomain,
ProxyRouteConfigSection,
ConfigVersionSummary,
ConfigVersionDetail,
ConfigDiffResult,
ConfigPreviewResult,
ApplyLogList,
DashboardOverview,
WAFIPGroup,
WAFRule,
WAFRuleGraph,
WAFRuleNode,
WAFRuleGroup,
WAFSiteRuleGroups,
TlsCertificateItem,
DnsAccountItem,
PagesProject,
PagesDeployment,
PagesSource,
PagesSourceActionReceipt,
PagesSourceStatus,
OriginItem,
OriginDetail,
AccessLogList,
AccessLogOverview,
OptionItem,
GeoIPLookupResult,
OpenFlarePublicStatus,
} from './openflare';
@@ -1,43 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
import { BaseService } from '@/lib/services/core';
import type {
CreateMessageChannelRequest,
MessageChannel,
MessageChannelDefinition,
UpdateMessageChannelRequest,
} from './types';
export class AdminMessageGatewayService extends BaseService {
protected static readonly basePath = '/api/v1/admin/message-gateway';
static async list(): Promise<MessageChannel[]> {
return this.get<MessageChannel[]>('/channels');
}
static async definitions(): Promise<MessageChannelDefinition[]> {
return this.get<MessageChannelDefinition[]>('/channels/definitions');
}
static async create(
data: CreateMessageChannelRequest,
): Promise<MessageChannel> {
return this.post<MessageChannel>('/channels', data);
}
static async update(
id: string,
data: UpdateMessageChannelRequest,
): Promise<MessageChannel> {
return this.patch<MessageChannel>(`/channels/${id}`, data);
}
static async remove(id: string): Promise<void> {
return this.delete<void>(`/channels/${id}`);
}
static async test(id: string): Promise<void> {
return this.post<void>(`/channels/${id}/test`);
}
}
@@ -1,6 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
export * from './types';
export * from './admin.service';
export * from './user.service';
@@ -1,73 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
export type MessageChannelType = 'telegram' | 'qq';
export interface MessageChannelField {
key: string;
type?: string;
required?: boolean;
}
export interface MessageChannelDefinition {
type: MessageChannelType | string;
name: string;
fields: MessageChannelField[];
}
export interface MessageChannel {
id: string;
name: string;
type: MessageChannelType | string;
owner_scope: string;
enabled: boolean;
bot_token?: string;
app_id?: string;
app_secret?: string;
base_url?: string;
portal_host?: string;
created_at: string;
updated_at: string;
}
export interface CreateMessageChannelRequest {
name: string;
type: string;
enabled?: boolean;
bot_token?: string;
app_id?: string;
app_secret?: string;
base_url?: string;
portal_host?: string;
}
export interface UpdateMessageChannelRequest {
name?: string;
enabled?: boolean;
bot_token?: string;
app_id?: string;
app_secret?: string;
base_url?: string;
portal_host?: string;
}
export interface PublicMessageChannel {
id: string;
name: string;
type: MessageChannelType | string;
}
export interface MessageBinding {
id: string;
user_id: string;
channel_id: string;
channel_name: string;
channel_type: string;
platform_user_id: string;
created_at: string;
}
export interface BindMessageChannelRequest {
channel_id: string;
code: string;
}
@@ -1,29 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
import { BaseService } from '@/lib/services/core';
import type {
BindMessageChannelRequest,
MessageBinding,
PublicMessageChannel,
} from './types';
export class UserMessageGatewayService extends BaseService {
protected static readonly basePath = '/api/v1/message-gateway';
static async listBindings(): Promise<MessageBinding[]> {
return this.get<MessageBinding[]>('/bindings');
}
static async listChannels(): Promise<PublicMessageChannel[]> {
return this.get<PublicMessageChannel[]>('/channels');
}
static async bind(data: BindMessageChannelRequest): Promise<MessageBinding> {
return this.post<MessageBinding>('/bindings', data);
}
static async unbind(id: string): Promise<void> {
return this.delete<void>(`/bindings/${id}`);
}
}
@@ -0,0 +1,98 @@
import { OpenFlareBaseService } from './base.service';
import type {
AccessLogCleanupPayload,
AccessLogCleanupResult,
AccessLogFilters,
AccessLogIPAnalysis,
AccessLogIPAnalysisFilters,
AccessLogIPSummaryFilters,
AccessLogIPSummaryList,
AccessLogIPTrend,
AccessLogIPTrendFilters,
AccessLogList,
AccessLogOverview,
AccessLogOverviewFilters,
FoldedAccessLogFilters,
FoldedAccessLogIPFilters,
FoldedAccessLogIPList,
FoldedAccessLogList,
} from './types';
function buildSearchParams(filters: object): Record<string, unknown> {
const params: Record<string, unknown> = {};
Object.entries(filters as Record<string, unknown>).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return;
if (Array.isArray(value)) {
const items = value
.map((item) => String(item).trim())
.filter((item) => item !== '');
if (items.length === 0) return;
params[key] = items;
return;
}
params[key] = value;
});
return params;
}
export class AccessLogService extends OpenFlareBaseService {
protected static override readonly basePath: string = '/api/v1/d/access-logs';
static list(filters: AccessLogFilters = {}): Promise<AccessLogList> {
return this.get<AccessLogList>('/', buildSearchParams(filters));
}
static getOverview(
filters: AccessLogOverviewFilters = {},
): Promise<AccessLogOverview> {
return this.get<AccessLogOverview>('/overview', buildSearchParams(filters));
}
static listFolds(
filters: FoldedAccessLogFilters,
): Promise<FoldedAccessLogList> {
return this.get<FoldedAccessLogList>('/folds', buildSearchParams(filters));
}
static listFoldIPs(
filters: FoldedAccessLogIPFilters,
): Promise<FoldedAccessLogIPList> {
return this.get<FoldedAccessLogIPList>(
'/folds/ip-summary',
buildSearchParams(filters),
);
}
static listIPSummaries(
filters: AccessLogIPSummaryFilters = {},
): Promise<AccessLogIPSummaryList> {
return this.get<AccessLogIPSummaryList>(
'/ip-summary',
buildSearchParams(filters),
);
}
static getIPTrend(
filters: AccessLogIPTrendFilters,
): Promise<AccessLogIPTrend> {
return this.get<AccessLogIPTrend>(
'/ip-summary/trend',
buildSearchParams(filters),
);
}
static getIPAnalysis(
filters: AccessLogIPAnalysisFilters,
): Promise<AccessLogIPAnalysis> {
return this.get<AccessLogIPAnalysis>(
'/ip-summary/analysis',
buildSearchParams(filters),
);
}
static cleanup(
payload: AccessLogCleanupPayload,
): Promise<AccessLogCleanupResult> {
return this.post<AccessLogCleanupResult>('/cleanup', payload);
}
}
@@ -0,0 +1,34 @@
import { OpenFlareBaseService } from './base.service';
import type {
ApplyLogCleanupPayload,
ApplyLogCleanupResult,
ApplyLogList,
ApplyLogListQuery,
} from './types';
export class ApplyLogService extends OpenFlareBaseService {
protected static override readonly basePath: string = '/api/v1/d/apply-logs';
static list(query: ApplyLogListQuery = {}): Promise<ApplyLogList> {
const params: Record<string, unknown> = {};
const normalizedNodeId = query.node_id?.trim();
if (normalizedNodeId) {
params.node_id = normalizedNodeId;
}
if (query.pageNo) {
params.pageNo = query.pageNo;
}
if (query.pageSize) {
params.pageSize = query.pageSize;
}
return this.get<ApplyLogList>('/', params);
}
static cleanup(
payload: ApplyLogCleanupPayload,
): Promise<ApplyLogCleanupResult> {
return this.post<ApplyLogCleanupResult>('/cleanup', payload);
}
}
@@ -0,0 +1,5 @@
import { BaseService } from '@/lib/services/core';
export class OpenFlareBaseService extends BaseService {
protected static override readonly basePath: string = '/api/v1/d';
}
@@ -0,0 +1,105 @@
import { OpenFlareBaseService } from './base.service';
import type {
CloudflareAvailableDomain,
CloudflareConnection,
CloudflareConnectionPayload,
CloudflareGroup,
CloudflareGroupDetail,
CloudflareGroupPayload,
CloudflareMember,
CloudflareMemberCreatePayload,
CloudflareOverview,
CloudflareSyncReceipt,
} from './types';
export const cloudflareQueryKey = ['openflare', 'cloudflare'] as const;
export class CloudflareService extends OpenFlareBaseService {
protected static override readonly basePath = '/api/v1/d/cloudflare';
static getConnection(): Promise<CloudflareConnection> {
return this.get<CloudflareConnection>('/connection');
}
static saveConnection(
payload: CloudflareConnectionPayload,
): Promise<CloudflareConnection> {
return this.put<CloudflareConnection>('/connection', payload);
}
static verifyConnection(): Promise<CloudflareConnection> {
return this.post<CloudflareConnection>('/connection/verify');
}
static clearConnection(): Promise<void> {
return this.post<void>('/connection/clear');
}
static getOverview(): Promise<CloudflareOverview> {
return this.get<CloudflareOverview>('/overview');
}
static listGroups(): Promise<CloudflareGroup[]> {
return this.get<CloudflareGroup[]>('/groups');
}
static getGroup(id: number): Promise<CloudflareGroupDetail> {
return this.get<CloudflareGroupDetail>(`/groups/${id}`);
}
static createGroup(
payload: CloudflareGroupPayload,
): Promise<CloudflareGroup> {
return this.post<CloudflareGroup>('/groups', payload);
}
static updateGroup(
id: number,
payload: CloudflareGroupPayload,
): Promise<CloudflareGroup> {
return this.post<CloudflareGroup>(`/groups/${id}/update`, payload);
}
static deleteGroup(id: number): Promise<void> {
return this.post<void>(`/groups/${id}/delete`);
}
static syncGroup(id: number): Promise<CloudflareSyncReceipt> {
return this.post<CloudflareSyncReceipt>(`/groups/${id}/sync`);
}
static listAvailableDomains(): Promise<CloudflareAvailableDomain[]> {
return this.get<CloudflareAvailableDomain[]>('/domains/available');
}
static createMember(
groupId: number,
payload: CloudflareMemberCreatePayload,
): Promise<CloudflareMember> {
return this.post<CloudflareMember>(`/groups/${groupId}/members`, payload);
}
static updateMember(
groupId: number,
memberId: number,
proxied: boolean,
): Promise<CloudflareMember> {
return this.post<CloudflareMember>(
`/groups/${groupId}/members/${memberId}/update`,
{ proxied },
);
}
static removeMember(groupId: number, memberId: number): Promise<void> {
return this.post<void>(`/groups/${groupId}/members/${memberId}/remove`);
}
static syncMember(
groupId: number,
memberId: number,
): Promise<CloudflareSyncReceipt> {
return this.post<CloudflareSyncReceipt>(
`/groups/${groupId}/members/${memberId}/sync`,
);
}
}
@@ -0,0 +1,56 @@
import type { InternalAxiosRequestConfig } from 'axios';
import { OpenFlareBaseService } from './base.service';
import type {
ConfigDiffResult,
ConfigPreviewResult,
ConfigVersionCleanupPayload,
ConfigVersionCleanupResult,
ConfigVersionDetail,
ConfigVersionSummary,
} from './types';
export class ConfigVersionService extends OpenFlareBaseService {
protected static override readonly basePath: string =
'/api/v1/d/config-versions';
static list(): Promise<ConfigVersionSummary[]> {
return this.get<ConfigVersionSummary[]>('/');
}
static getActive(): Promise<ConfigVersionDetail> {
return this.get<ConfigVersionDetail>('/active');
}
static preview(): Promise<ConfigPreviewResult> {
return this.get<ConfigPreviewResult>('/preview');
}
static diff(): Promise<ConfigDiffResult> {
return this.get<ConfigDiffResult>('/diff');
}
static getById(id: string): Promise<ConfigVersionDetail> {
return this.get<ConfigVersionDetail>(`/${id}`);
}
static publish(force?: boolean): Promise<ConfigVersionDetail> {
return this.post<ConfigVersionDetail>(
'/publish',
undefined,
force
? ({ params: { force: true } } as InternalAxiosRequestConfig)
: undefined,
);
}
static activate(id: string): Promise<ConfigVersionDetail> {
return this.post<ConfigVersionDetail>(`/${id}/activate`);
}
static cleanup(
payload: ConfigVersionCleanupPayload,
): Promise<ConfigVersionCleanupResult> {
return this.post<ConfigVersionCleanupResult>('/cleanup', payload);
}
}
@@ -0,0 +1,251 @@
import { OpenFlareBaseService } from './base.service';
import type {
CompactCapacityTrendPoint,
CompactDashboardNodeHealth,
CompactDiskIOTrendPoint,
CompactDistributionItem,
CompactNetworkTrendPoint,
CompactTrafficTrendPoint,
DashboardCapacity,
DashboardNodeHealth,
DashboardOverview,
DashboardOverviewCompact,
DashboardSummary,
DashboardTraffic,
DistributionItem,
} from './types';
function arrayOrEmpty<T>(value: T[] | null | undefined) {
return Array.isArray(value) ? value : [];
}
function isCompactDistributionItem(
value: DistributionItem | CompactDistributionItem,
): value is CompactDistributionItem {
return Array.isArray(value);
}
function isCompactTrafficTrendPoint(
value:
| DashboardOverview['trends']['traffic_24h'][number]
| CompactTrafficTrendPoint,
): value is CompactTrafficTrendPoint {
return Array.isArray(value);
}
function isCompactCapacityTrendPoint(
value:
| DashboardOverview['trends']['capacity_24h'][number]
| CompactCapacityTrendPoint,
): value is CompactCapacityTrendPoint {
return Array.isArray(value);
}
function isCompactNetworkTrendPoint(
value:
| DashboardOverview['trends']['network_24h'][number]
| CompactNetworkTrendPoint,
): value is CompactNetworkTrendPoint {
return Array.isArray(value);
}
function isCompactDiskIOTrendPoint(
value:
| DashboardOverview['trends']['disk_io_24h'][number]
| CompactDiskIOTrendPoint,
): value is CompactDiskIOTrendPoint {
return Array.isArray(value);
}
function isCompactDashboardNode(
value: DashboardNodeHealth | CompactDashboardNodeHealth,
): value is CompactDashboardNodeHealth {
return Array.isArray(value);
}
function normalizeDistributionItems(
items: Array<DistributionItem | CompactDistributionItem> | null | undefined,
): DistributionItem[] {
return arrayOrEmpty(items).map((item) =>
isCompactDistributionItem(item)
? { key: String(item[0] ?? ''), value: Number(item[1] ?? 0) }
: item,
);
}
function normalizeTrafficTrendPoints(
items:
| Array<
| DashboardOverview['trends']['traffic_24h'][number]
| CompactTrafficTrendPoint
>
| null
| undefined,
) {
return arrayOrEmpty(items).map((item) =>
isCompactTrafficTrendPoint(item)
? {
bucket_started_at: String(item[0] ?? ''),
request_count: Number(item[1] ?? 0),
error_count: Number(item[2] ?? 0),
unique_visitor_count: Number(item[3] ?? 0),
status_2xx_count: Number(item[4] ?? 0),
status_4xx_count: Number(item[5] ?? 0),
status_5xx_count: Number(item[6] ?? 0),
}
: item,
);
}
function normalizeCapacityTrendPoints(
items:
| Array<
| DashboardOverview['trends']['capacity_24h'][number]
| CompactCapacityTrendPoint
>
| null
| undefined,
) {
return arrayOrEmpty(items).map((item) =>
isCompactCapacityTrendPoint(item)
? {
bucket_started_at: String(item[0] ?? ''),
average_cpu_usage_percent: Number(item[1] ?? 0),
average_memory_usage_percent: Number(item[2] ?? 0),
reported_nodes: Number(item[3] ?? 0),
}
: item,
);
}
function normalizeNetworkTrendPoints(
items:
| Array<
| DashboardOverview['trends']['network_24h'][number]
| CompactNetworkTrendPoint
>
| null
| undefined,
) {
return arrayOrEmpty(items).map((item) =>
isCompactNetworkTrendPoint(item)
? {
bucket_started_at: String(item[0] ?? ''),
bytes_received: Number(item[1] ?? 0),
bytes_provided: Number(item[2] ?? 0),
reported_nodes: Number(item[3] ?? 0),
}
: {
...item,
bytes_received: Number(
(item as { bytes_received?: number }).bytes_received ?? 0,
),
bytes_provided: Number(
(item as { bytes_provided?: number }).bytes_provided ?? 0,
),
},
);
}
function normalizeDiskIOTrendPoints(
items:
| Array<
| DashboardOverview['trends']['disk_io_24h'][number]
| CompactDiskIOTrendPoint
>
| null
| undefined,
) {
return arrayOrEmpty(items).map((item) =>
isCompactDiskIOTrendPoint(item)
? {
bucket_started_at: String(item[0] ?? ''),
disk_read_bytes: Number(item[1] ?? 0),
disk_write_bytes: Number(item[2] ?? 0),
reported_nodes: Number(item[3] ?? 0),
}
: item,
);
}
function normalizeDashboardNodes(
items:
| Array<DashboardNodeHealth | CompactDashboardNodeHealth>
| null
| undefined,
): DashboardNodeHealth[] {
return arrayOrEmpty(items).map((item) =>
isCompactDashboardNode(item)
? {
id: Number(item[0] ?? 0),
node_id: String(item[1] ?? ''),
name: String(item[2] ?? ''),
geo_name: String(item[3] ?? ''),
geo_latitude:
item[4] === null || item[4] === undefined ? null : Number(item[4]),
geo_longitude:
item[5] === null || item[5] === undefined ? null : Number(item[5]),
status: (item[6] ?? 'pending') as DashboardNodeHealth['status'],
openresty_status: (item[7] ??
'unknown') as DashboardNodeHealth['openresty_status'],
current_version: String(item[8] ?? ''),
last_seen_at: String(item[9] ?? ''),
active_event_count: Number(item[10] ?? 0),
cpu_usage_percent: Number(item[11] ?? 0),
memory_usage_percent: Number(item[12] ?? 0),
storage_usage_percent: Number(item[13] ?? 0),
request_count: Number(item[14] ?? 0),
error_count: Number(item[15] ?? 0),
unique_visitor_count: Number(item[16] ?? 0),
}
: item,
);
}
function normalizeDashboardOverview(
overview: DashboardOverview | DashboardOverviewCompact | null | undefined,
): DashboardOverview | null {
if (!overview) {
return null;
}
const summary = (overview.summary ?? {}) as DashboardSummary;
const traffic = (overview.traffic ?? {}) as DashboardTraffic;
const capacity = (overview.capacity ?? {}) as DashboardCapacity;
return {
generated_at: String(overview.generated_at ?? ''),
summary,
traffic,
capacity,
nodes: normalizeDashboardNodes(overview.nodes),
distributions: {
source_countries: normalizeDistributionItems(
overview.distributions?.source_countries,
),
status_codes: normalizeDistributionItems(
overview.distributions?.status_codes,
),
top_domains: normalizeDistributionItems(
overview.distributions?.top_domains,
),
},
trends: {
traffic_24h: normalizeTrafficTrendPoints(overview.trends?.traffic_24h),
capacity_24h: normalizeCapacityTrendPoints(overview.trends?.capacity_24h),
network_24h: normalizeNetworkTrendPoints(overview.trends?.network_24h),
disk_io_24h: normalizeDiskIOTrendPoints(overview.trends?.disk_io_24h),
},
};
}
export class DashboardService extends OpenFlareBaseService {
protected static override readonly basePath: string = '/api/v1/d/dashboard';
static async getOverview(): Promise<DashboardOverview | null> {
const overview = await this.get<
DashboardOverview | DashboardOverviewCompact
>('/overview');
return normalizeDashboardOverview(overview);
}
}
@@ -0,0 +1,28 @@
import { OpenFlareBaseService } from './base.service';
import type { DnsAccountItem, DnsAccountMutationPayload } from './types';
export class DnsAccountService extends OpenFlareBaseService {
protected static override readonly basePath: string =
'/api/v1/d/dns-accounts';
static async list(): Promise<DnsAccountItem[]> {
return this.get<DnsAccountItem[]>('/');
}
static async create(
payload: DnsAccountMutationPayload,
): Promise<DnsAccountItem> {
return this.post<DnsAccountItem>('/', payload);
}
static async update(
id: number,
payload: DnsAccountMutationPayload,
): Promise<DnsAccountItem> {
return this.post<DnsAccountItem>(`/${id}/update`, payload);
}
static async deleteById(id: number): Promise<void> {
return this.post<void>(`/${id}/delete`);
}
}
+197
View File
@@ -0,0 +1,197 @@
export { OpenFlareBaseService } from './base.service';
export { NodeService } from './node.service';
export { ProxyRouteService } from './proxy-route.service';
export { ConfigVersionService } from './config-version.service';
export { ApplyLogService } from './apply-log.service';
export { DashboardService } from './dashboard.service';
export { WafService } from './waf.service';
export { ZoneDomainService, ZoneService, zoneQueryKey } from './zone.service';
export { TlsCertificateService } from './tls-certificate.service';
export { DnsAccountService } from './dns-account.service';
export { PagesService } from './pages.service';
export { OriginService } from './origin.service';
export { AccessLogService } from './access-log.service';
export { OptionService } from './option.service';
export { UptimeKumaService } from './uptimekuma.service';
export { StatusService } from './status.service';
export {
CloudflareService,
cloudflareQueryKey,
} from './cloudflare.service';
export type {
ApplyLogCleanupPayload,
ApplyLogCleanupResult,
ApplyLogItem,
ApplyLogList,
ApplyLogListQuery,
ApplyResult,
ConfigDiffResult,
ConfigOptionDiffItem,
ConfigPreviewResult,
ConfigVersionCleanupPayload,
ConfigVersionCleanupResult,
ConfigVersionDetail,
ConfigVersionSummary,
NodeAgentReleaseInfo,
NodeAgentUpdatePayload,
NodeBootstrapToken,
NodeItem,
NodeMutationPayload,
NodeHealthEvent,
NodeObservability,
NodeObservabilityAnalytics,
NodeObservabilityTrends,
NodeSystemProfile,
NodeStatus,
NodeType,
OpenrestyStatus,
ProxyRouteConfigSection,
ProxyRouteCustomHeader,
ProxyRouteItem,
ProxyRouteMutationPayload,
ProxyRoutePoWConfig,
ProxyRouteZoneDomain,
ReleaseChannel,
SupportFile,
DashboardOverview,
DashboardNodeHealth,
DashboardSummary,
DashboardTraffic,
DashboardCapacity,
DistributionItem,
TrafficDistributions,
TrafficTrendPoint,
CapacityTrendPoint,
NetworkTrendPoint,
DiskIOTrendPoint,
WAFIPGroup,
WAFIPGroupAutoTestPayload,
WAFIPGroupAutoTestResult,
WAFIPGroupPayload,
WAFIPGroupSyncResult,
WAFIPGroupSubscriptionFormat,
WAFIPGroupType,
WAFCreateRulePayload,
WAFRule,
WAFRuleEdge,
WAFRuleGraph,
WAFRuleNode,
WAFSaveRuleGraphPayload,
WAFUpdateRuleMetaPayload,
WAFRuleGroup,
WAFRuleGroupPayload,
WAFSiteRuleGroups,
AccessLogCleanupPayload,
AccessLogCleanupResult,
AccessLogFilters,
AccessLogIPAnalysis,
AccessLogIPAnalysisFilters,
AccessLogIPSummaryFilters,
AccessLogIPSummaryItem,
AccessLogIPSummaryList,
AccessLogIPTrend,
AccessLogIPTrendFilters,
AccessLogItem,
AccessLogList,
AccessLogOverview,
AccessLogOverviewFilters,
AccessLogOverviewMetricPoint,
FoldedAccessLogFilters,
FoldedAccessLogIPFilters,
FoldedAccessLogIPList,
FoldedAccessLogList,
OptionItem,
GeoIPLookupResult,
OpenFlarePublicStatus,
OriginDetail,
OriginItem,
OriginMutationPayload,
PagesDeployment,
PagesDeploymentFile,
PagesGitHubLatestSourceUpdatePayload,
PagesGitHubReleaseSelector,
PagesGitHubReleaseSource,
PagesGitHubSourceUpdatePayload,
PagesGitHubTagSourceUpdatePayload,
PagesManualSource,
PagesProject,
PagesProjectPayload,
PagesRemoteSourceUpdatePayload,
PagesRemoteURLSource,
PagesSource,
PagesSourceActionPayload,
PagesSourceActionReceipt,
PagesSourceRevision,
PagesSourceStatus,
PagesSourceUpdatePayload,
PagesSourceUpdateResult,
AcmeAccountItem,
DnsAccountItem,
DnsAccountMutationPayload,
ZoneDomainItem,
ZoneDomainMutationPayload,
ZoneItem,
ZoneMutationPayload,
ZoneOverview,
ZoneStats,
ZoneStatsPoint,
ZoneStatsRange,
TlsCertificateApplyPayload,
TlsCertificateContentItem,
TlsCertificateDetailItem,
TlsCertificateFileImportPayload,
TlsCertificateItem,
TlsCertificateMutationPayload,
CloudflareAvailableDomain,
CloudflareConnection,
CloudflareConnectionPayload,
CloudflareConnectionSource,
CloudflareGroup,
CloudflareGroupDetail,
CloudflareGroupPayload,
CloudflareMember,
CloudflareMemberCreatePayload,
CloudflareNodeOption,
CloudflareOverview,
CloudflareSyncReceipt,
CloudflareSyncStatus,
} from './types';
import { AccessLogService } from './access-log.service';
import { ApplyLogService } from './apply-log.service';
import { ConfigVersionService } from './config-version.service';
import { DashboardService } from './dashboard.service';
import { DnsAccountService } from './dns-account.service';
import { NodeService } from './node.service';
import { OptionService } from './option.service';
import { StatusService } from './status.service';
import { UptimeKumaService } from './uptimekuma.service';
import { OriginService } from './origin.service';
import { PagesService } from './pages.service';
import { ProxyRouteService } from './proxy-route.service';
import { TlsCertificateService } from './tls-certificate.service';
import { WafService } from './waf.service';
import { ZoneDomainService, ZoneService } from './zone.service';
import { CloudflareService } from './cloudflare.service';
export const openflareServices = {
node: NodeService,
proxyRoute: ProxyRouteService,
configVersion: ConfigVersionService,
applyLog: ApplyLogService,
dashboard: DashboardService,
waf: WafService,
zone: ZoneService,
zoneDomain: ZoneDomainService,
tlsCertificate: TlsCertificateService,
dnsAccount: DnsAccountService,
pages: PagesService,
origin: OriginService,
accessLog: AccessLogService,
option: OptionService,
uptimeKuma: UptimeKumaService,
status: StatusService,
cloudflare: CloudflareService,
} as const;
@@ -0,0 +1,81 @@
import { OpenFlareBaseService } from './base.service';
import type {
NodeAgentReleaseInfo,
NodeAgentUpdatePayload,
NodeBootstrapToken,
NodeItem,
NodeMutationPayload,
NodeObservability,
ReleaseChannel,
} from './types';
export class NodeService extends OpenFlareBaseService {
protected static override readonly basePath: string = '/api/v1/d/nodes';
static async listNodes(): Promise<NodeItem[]> {
return this.get<NodeItem[]>('/');
}
static async createNode(payload: NodeMutationPayload): Promise<NodeItem> {
return this.post<NodeItem>('/', payload);
}
static async updateNode(
id: number,
payload: NodeMutationPayload,
): Promise<NodeItem> {
return this.post<NodeItem>(`/${id}/update`, payload);
}
static async deleteNode(id: number): Promise<void> {
return this.post<void>(`/${id}/delete`);
}
static async getBootstrapToken(): Promise<NodeBootstrapToken> {
return this.get<NodeBootstrapToken>('/bootstrap-token');
}
static async rotateBootstrapToken(): Promise<NodeBootstrapToken> {
return this.post<NodeBootstrapToken>('/bootstrap-token/rotate');
}
static async requestAgentUpdate(
id: number,
payload?: NodeAgentUpdatePayload,
): Promise<NodeItem> {
return this.post<NodeItem>(`/${id}/agent-update`, payload ?? {});
}
static async requestForceSync(id: number): Promise<NodeItem> {
return this.post<NodeItem>(`/${id}/force-sync`);
}
static async requestOpenrestyRestart(id: number): Promise<NodeItem> {
return this.post<NodeItem>(`/${id}/openresty-restart`);
}
static async getAgentRelease(
id: number,
channel: ReleaseChannel = 'stable',
): Promise<NodeAgentReleaseInfo> {
return this.get<NodeAgentReleaseInfo>(`/${id}/agent-release`, { channel });
}
static async getObservability(
id: number,
options?: { hours?: number; limit?: number },
): Promise<NodeObservability> {
return this.get<NodeObservability>(`/${id}/observability`, {
hours: options?.hours,
limit: options?.limit,
});
}
static async cleanupHealthEvents(
id: number,
): Promise<{ node_id: string; deleted_count: number }> {
return this.post<{ node_id: string; deleted_count: number }>(
`/${id}/observability/cleanup`,
);
}
}
@@ -0,0 +1,27 @@
import { OpenFlareBaseService } from './base.service';
import type {
GeoIPLookupResult,
OptionBatchPayload,
OptionItem,
} from './types';
export class OptionService extends OpenFlareBaseService {
protected static override readonly basePath: string = '/api/v1/d/option';
static list(): Promise<OptionItem[]> {
return this.get<OptionItem[]>('/');
}
static update(key: string, value: string): Promise<void> {
return this.post<void>('/update', { key, value });
}
static updateBatch(options: OptionItem[]): Promise<void> {
const payload: OptionBatchPayload = { options };
return this.post<void>('/update-batch', payload);
}
static lookupGeoIP(provider: string, ip: string): Promise<GeoIPLookupResult> {
return this.post<GeoIPLookupResult>('/geoip/lookup', { provider, ip });
}
}
@@ -0,0 +1,29 @@
import { OpenFlareBaseService } from './base.service';
import type { OriginDetail, OriginItem, OriginMutationPayload } from './types';
export class OriginService extends OpenFlareBaseService {
protected static override readonly basePath: string = '/api/v1/d/origins';
static list(): Promise<OriginItem[]> {
return this.get<OriginItem[]>('/');
}
static getById(id: number): Promise<OriginDetail> {
return this.get<OriginDetail>(`/${id}`);
}
static create(payload: OriginMutationPayload): Promise<OriginItem> {
return this.post<OriginItem>('/', payload);
}
static update(
id: number,
payload: OriginMutationPayload,
): Promise<OriginItem> {
return this.post<OriginItem>(`/${id}/update`, payload);
}
static deleteById(id: number): Promise<void> {
return this.post<void>(`/${id}/delete`);
}
}
@@ -0,0 +1,157 @@
import type { AxiosProgressEvent, InternalAxiosRequestConfig } from 'axios';
import apiClient from '@/lib/services/core/api-client';
import { apiConfig } from '@/lib/services/core/config';
import type { ApiResponse } from '@/lib/services/core';
import { OpenFlareBaseService } from './base.service';
import type {
PagesDeployment,
PagesDeploymentFile,
PagesDeploymentUploadFromURLPayload,
PagesDeploymentUploadPayload,
PagesProject,
PagesProjectPayload,
PagesSource,
PagesSourceActionPayload,
PagesSourceActionReceipt,
PagesSourceUpdatePayload,
PagesSourceUpdateResult,
} from './types';
export class PagesService extends OpenFlareBaseService {
protected static override readonly basePath: string = '/api/v1/d/pages';
static listProjects(): Promise<PagesProject[]> {
return this.get<PagesProject[]>('/');
}
static getProject(id: number): Promise<PagesProject> {
return this.get<PagesProject>(`/${id}`);
}
static createProject(payload: PagesProjectPayload): Promise<PagesProject> {
return this.post<PagesProject>('/', payload);
}
static updateProject(
id: number,
payload: PagesProjectPayload,
): Promise<PagesProject> {
return this.post<PagesProject>(`/${id}/update`, payload);
}
static deleteProject(id: number): Promise<void> {
return this.post<void>(`/${id}/delete`);
}
static getSource(projectId: number): Promise<PagesSource> {
return this.get<PagesSource>(`/${projectId}/source`);
}
static updateSource(
projectId: number,
payload: PagesSourceUpdatePayload,
): Promise<PagesSourceUpdateResult> {
return this.post<PagesSourceUpdateResult>(
`/${projectId}/source/update`,
payload,
);
}
static deleteSource(projectId: number): Promise<PagesSource> {
return this.post<PagesSource>(`/${projectId}/source/delete`);
}
static checkSource(projectId: number): Promise<PagesSourceActionReceipt> {
return this.post<PagesSourceActionReceipt>(
`/${projectId}/source/check`,
{},
);
}
static syncSource(
projectId: number,
payload: PagesSourceActionPayload = {},
): Promise<PagesSourceActionReceipt> {
return this.post<PagesSourceActionReceipt>(
`/${projectId}/source/sync`,
payload,
);
}
static listDeployments(projectId: number): Promise<PagesDeployment[]> {
return this.get<PagesDeployment[]>(`/${projectId}/deployments`);
}
static listDeploymentFiles(
deploymentId: number,
): Promise<PagesDeploymentFile[]> {
return this.get<PagesDeploymentFile[]>(
`/deployments/${deploymentId}/files`,
);
}
static uploadDeployment(
projectId: number,
payload: PagesDeploymentUploadPayload,
): Promise<PagesDeployment> {
const formData = new FormData();
formData.append('package', payload.file);
return this.postFormData<PagesDeployment>(
`/${projectId}/deployments/upload`,
formData,
payload.onProgress,
);
}
static uploadDeploymentFromURL(
projectId: number,
payload: PagesDeploymentUploadFromURLPayload,
): Promise<PagesDeployment> {
return this.post<PagesDeployment>(
`/${projectId}/deployments/upload-from-url`,
payload,
{ timeout: apiConfig.uploadTimeout } as InternalAxiosRequestConfig,
);
}
static activateDeployment(
projectId: number,
deploymentId: number,
): Promise<PagesProject> {
return this.post<PagesProject>(
`/${projectId}/deployments/${deploymentId}/activate`,
);
}
static deleteDeployment(
projectId: number,
deploymentId: number,
): Promise<void> {
return this.post<void>(`/${projectId}/deployments/${deploymentId}/delete`);
}
private static async postFormData<T>(
path: string,
formData: FormData,
onProgress?: (percent: number) => void,
): Promise<T> {
const response = await apiClient.post<ApiResponse<T>>(
this.getFullPath(path),
formData,
{
timeout: apiConfig.uploadTimeout,
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (event: AxiosProgressEvent) => {
if (!onProgress || !event.total) return;
const percent = Math.round((event.loaded / event.total) * 100);
onProgress(percent);
},
} as InternalAxiosRequestConfig,
);
return response.data.data;
}
}
@@ -0,0 +1,32 @@
import { OpenFlareBaseService } from './base.service';
import type { ProxyRouteItem, ProxyRouteMutationPayload } from './types';
export class ProxyRouteService extends OpenFlareBaseService {
protected static override readonly basePath: string =
'/api/v1/d/proxy-routes';
static async list(): Promise<ProxyRouteItem[]> {
return this.get<ProxyRouteItem[]>('/');
}
static async getById(id: number): Promise<ProxyRouteItem> {
return this.get<ProxyRouteItem>(`/${id}`);
}
static async create(
payload: ProxyRouteMutationPayload,
): Promise<ProxyRouteItem> {
return this.post<ProxyRouteItem>('/', payload);
}
static async update(
id: number,
payload: ProxyRouteMutationPayload,
): Promise<ProxyRouteItem> {
return this.post<ProxyRouteItem>(`/${id}/update`, payload);
}
static async deleteById(id: number): Promise<void> {
return this.post<void>(`/${id}/delete`);
}
}
@@ -0,0 +1,8 @@
import { OpenFlareBaseService } from './base.service';
import type { OpenFlarePublicStatus } from './types';
export class StatusService extends OpenFlareBaseService {
static getPublicStatus(): Promise<OpenFlarePublicStatus> {
return this.get<OpenFlarePublicStatus>('/status');
}
}
@@ -0,0 +1,93 @@
import { OpenFlareBaseService } from './base.service';
import type {
AcmeAccountItem,
TlsCertificateApplyPayload,
TlsCertificateContentItem,
TlsCertificateDetailItem,
TlsCertificateFileImportPayload,
TlsCertificateItem,
TlsCertificateMutationPayload,
} from './types';
class AcmeAccountApi extends OpenFlareBaseService {
protected static override readonly basePath: string =
'/api/v1/d/acme-accounts';
static getDefault(): Promise<AcmeAccountItem> {
return this.get<AcmeAccountItem>('/default');
}
}
export class TlsCertificateService extends OpenFlareBaseService {
protected static override readonly basePath: string =
'/api/v1/d/tls-certificates';
static async list(): Promise<TlsCertificateItem[]> {
return this.get<TlsCertificateItem[]>('/');
}
static async getById(id: number): Promise<TlsCertificateDetailItem> {
return this.get<TlsCertificateDetailItem>(`/${id}`);
}
static async getContent(id: number): Promise<TlsCertificateContentItem> {
return this.get<TlsCertificateContentItem>(`/${id}/content`);
}
static async create(
payload: TlsCertificateMutationPayload,
): Promise<TlsCertificateItem> {
return this.post<TlsCertificateItem>('/', payload);
}
static async update(
id: number,
payload: TlsCertificateMutationPayload,
): Promise<TlsCertificateItem> {
return this.post<TlsCertificateItem>(`/${id}/update`, payload);
}
static async deleteById(id: number): Promise<void> {
return this.post<void>(`/${id}/delete`);
}
static async apply(
payload: TlsCertificateApplyPayload,
): Promise<TlsCertificateItem> {
return this.post<TlsCertificateItem>('/apply', payload);
}
static async renew(id: number): Promise<TlsCertificateItem> {
return this.post<TlsCertificateItem>(`/${id}/renew`);
}
static async updateAcme(
id: number,
payload: TlsCertificateApplyPayload,
): Promise<TlsCertificateItem> {
return this.post<TlsCertificateItem>(`/${id}/update-acme`, payload);
}
static async convertToAcme(
id: number,
payload: TlsCertificateApplyPayload,
): Promise<TlsCertificateItem> {
return this.post<TlsCertificateItem>(`/${id}/convert-acme`, payload);
}
static async importFile(
payload: TlsCertificateFileImportPayload,
): Promise<TlsCertificateItem> {
const formData = new FormData();
formData.append('name', payload.name);
formData.append('remark', payload.remark);
formData.append('cert_file', payload.certFile);
formData.append('key_file', payload.keyFile);
return this.post<TlsCertificateItem>('/import-file', formData);
}
static getDefaultAcmeAccount(): Promise<AcmeAccountItem> {
return AcmeAccountApi.getDefault();
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,9 @@
import { OpenFlareBaseService } from './base.service';
export class UptimeKumaService extends OpenFlareBaseService {
protected static override readonly basePath: string = '/api/v1/d/uptimekuma';
static sync(): Promise<void> {
return this.post<void>('/sync');
}
}
@@ -0,0 +1,93 @@
import { OpenFlareBaseService } from './base.service';
import type {
WAFIPGroup,
WAFIPGroupAutoTestPayload,
WAFIPGroupAutoTestResult,
WAFIPGroupPayload,
WAFIPGroupSyncResult,
WAFCreateRulePayload,
WAFRule,
WAFSaveRuleGraphPayload,
WAFSiteRuleGroups,
WAFUpdateRuleMetaPayload,
} from './types';
export class WafService extends OpenFlareBaseService {
protected static override readonly basePath: string = '/api/v1/d/waf';
static async listRuleGroups(): Promise<WAFRule[]> {
return this.get<WAFRule[]>('/rule-groups');
}
static async getRule(id: number): Promise<WAFRule> {
return this.get<WAFRule>(`/rule-groups/${id}`);
}
static async createRule(payload: WAFCreateRulePayload): Promise<WAFRule> {
return this.post<WAFRule>('/rule-groups', payload);
}
static async saveRuleGraph(
id: number,
payload: WAFSaveRuleGraphPayload,
): Promise<WAFRule> {
return this.post<WAFRule>(`/rule-groups/${id}/graph`, payload);
}
static async updateRuleMeta(
id: number,
payload: WAFUpdateRuleMetaPayload,
): Promise<WAFRule> {
return this.post<WAFRule>(`/rule-groups/${id}/meta`, payload);
}
static async deleteRuleGroup(id: number): Promise<void> {
return this.post<void>(`/rule-groups/${id}/delete`);
}
static async listSiteRuleGroups(routeId: number): Promise<WAFSiteRuleGroups> {
return this.get<WAFSiteRuleGroups>(`/sites/${routeId}/rule-groups`);
}
static async updateSiteRuleGroups(
routeId: number,
ids: number[],
): Promise<WAFSiteRuleGroups> {
return this.post<WAFSiteRuleGroups>(`/sites/${routeId}/rule-groups`, {
ids,
});
}
static async listIPGroups(): Promise<WAFIPGroup[]> {
return this.get<WAFIPGroup[]>('/ip-groups');
}
static async getIPGroup(id: number): Promise<WAFIPGroup> {
return this.get<WAFIPGroup>(`/ip-groups/${id}`);
}
static async createIPGroup(payload: WAFIPGroupPayload): Promise<WAFIPGroup> {
return this.post<WAFIPGroup>('/ip-groups', payload);
}
static async updateIPGroup(
id: number,
payload: WAFIPGroupPayload,
): Promise<WAFIPGroup> {
return this.post<WAFIPGroup>(`/ip-groups/${id}/update`, payload);
}
static async deleteIPGroup(id: number): Promise<void> {
return this.post<void>(`/ip-groups/${id}/delete`);
}
static async testIPGroup(
payload: WAFIPGroupAutoTestPayload,
): Promise<WAFIPGroupAutoTestResult> {
return this.post<WAFIPGroupAutoTestResult>('/ip-groups/test', payload);
}
static async syncIPGroup(id: number): Promise<WAFIPGroupSyncResult> {
return this.post<WAFIPGroupSyncResult>(`/ip-groups/${id}/sync`);
}
}
@@ -0,0 +1,62 @@
import { OpenFlareBaseService } from './base.service';
import type {
ZoneDomainItem,
ZoneDomainMutationPayload,
ZoneItem,
ZoneMutationPayload,
ZoneOverview,
ZoneStats,
ZoneStatsRange,
} from './types';
export const zoneQueryKey = ['openflare', 'zones'] as const;
export class ZoneService extends OpenFlareBaseService {
protected static override readonly basePath = '/api/v1/d/zones';
static list(): Promise<ZoneItem[]> {
return this.get<ZoneItem[]>('/');
}
static getOverview(id: number): Promise<ZoneOverview> {
return this.get<ZoneOverview>(`/${id}/overview`);
}
static getStats(
id: number,
range: ZoneStatsRange = '24h',
): Promise<ZoneStats> {
return this.get<ZoneStats>(`/${id}/stats`, { range });
}
static create(payload: ZoneMutationPayload): Promise<ZoneItem> {
return this.post<ZoneItem>('/', payload);
}
static update(id: number, payload: ZoneMutationPayload): Promise<ZoneItem> {
return this.post<ZoneItem>(`/${id}/update`, payload);
}
static deleteById(id: number): Promise<void> {
return this.post<void>(`/${id}/delete`);
}
}
export class ZoneDomainService extends OpenFlareBaseService {
protected static override readonly basePath = '/api/v1/d/zones';
static create(
zoneId: number,
payload: ZoneDomainMutationPayload,
): Promise<ZoneDomainItem> {
return this.post<ZoneDomainItem>(`/${zoneId}/domains`, payload);
}
static update(
zoneId: number,
id: number,
payload: ZoneDomainMutationPayload,
): Promise<ZoneDomainItem> {
return this.post<ZoneDomainItem>(
`/${zoneId}/domains/${id}/update`,
payload,
);
}
static deleteById(zoneId: number, id: number): Promise<void> {
return this.post<void>(`/${zoneId}/domains/${id}/delete`);
}
}
@@ -1,5 +1,6 @@
import type { InternalAxiosRequestConfig } from 'axios';
import { apiConfig } from '../core/config';
import { BaseService } from '../core/base.service';
import type { ListUploadsResponse, Upload, UploadImageResponse } from './types';
@@ -23,6 +24,7 @@ export class UploadService extends BaseService {
}
return this.post<Upload>('', formData, {
timeout: apiConfig.uploadTimeout,
headers: { 'Content-Type': 'multipart/form-data' },
} as InternalAxiosRequestConfig);
}
+1 -1
View File
@@ -2,10 +2,10 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
useCallback,
} from 'react';
import { useTheme as useNextTheme } from 'next-themes';
import type { Theme } from './types';
+2 -2
View File
@@ -1,4 +1,4 @@
import { clsx, type ClassValue } from 'clsx';
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
@@ -105,7 +105,7 @@ export function generateTransactionCacheKey(params: {
*/
export function safeRedirectTarget(
url: string | null | undefined,
fallback = '/home',
fallback = '/',
): string {
if (!url) return fallback;
+83
View File
@@ -0,0 +1,83 @@
const compactNumberFormatter = new Intl.NumberFormat('zh-CN', {
maximumFractionDigits: 1,
});
type FormatBytesOptions = {
emptyText?: string;
zeroText?: string;
};
export function formatCompactNumber(value?: number | null) {
if (value === undefined || value === null || Number.isNaN(value)) {
return '—';
}
return compactNumberFormatter.format(value);
}
export function formatPercent(value?: number | null) {
if (value === undefined || value === null || Number.isNaN(value)) {
return '—';
}
return `${formatCompactNumber(value)}%`;
}
export function formatBytes(
value?: number | null,
options: FormatBytesOptions = {},
) {
const { emptyText = '—', zeroText = '0 B' } = options;
if (value === undefined || value === null || Number.isNaN(value)) {
return emptyText;
}
if (value <= 0) {
return zeroText;
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let current = value;
let index = 0;
while (current >= 1024 && index < units.length - 1) {
current /= 1024;
index += 1;
}
const digits = current >= 100 || index === 0 ? 0 : 1;
return `${new Intl.NumberFormat('zh-CN', {
maximumFractionDigits: digits,
minimumFractionDigits: 0,
}).format(current)} ${units[index]}`;
}
export function formatBytesPerSecond(
value?: number | null,
windowSeconds = 1,
options?: FormatBytesOptions,
) {
if (value === undefined || value === null || Number.isNaN(value)) {
return options?.emptyText ?? '—';
}
if (windowSeconds <= 0) {
return options?.emptyText ?? '—';
}
return `${formatBytes(value / windowSeconds, options)}/s`;
}
export function calculateNiceAxisMax(values: number[]) {
const rawMax = Math.max(
0,
...values.map((value) => (Number.isFinite(value) && value > 0 ? value : 0)),
);
if (rawMax <= 0) {
return 1;
}
const paddedMax = rawMax * 1.1;
const magnitude = 10 ** Math.floor(Math.log10(paddedMax));
const normalized = paddedMax / magnitude;
const steps = [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10];
const niceStep = steps.find((step) => normalized <= step) ?? 10;
return niceStep * magnitude;
}
+246 -37
View File
@@ -29,14 +29,19 @@ const match = ((): MatchFunction | null => {
return null;
})();
export interface SearchItem {
export interface SearchItemSource {
id: string;
title: string;
description: string;
titleKey: string;
descriptionKey: string;
url: string;
category: 'page' | 'feature' | 'setting' | 'admin';
keywords: string[];
icon?: string;
}
export interface SearchItem extends SearchItemSource {
title: string;
description: string;
matchRange?: [number, number];
}
@@ -44,65 +49,220 @@ export interface SearchItem {
* 全局搜索数据源
* 包含所有可搜索的页面和功能
*/
export const searchData: SearchItem[] = [
// ==================== 首页 ====================
export const searchData: SearchItemSource[] = [
// ==================== 总览 ====================
{
id: 'home',
title: '首页',
description: '返回首页控制台',
url: '/home',
titleKey: 'nav.dashboard',
descriptionKey: 'search.items.homeDesc',
url: '/',
category: 'page',
keywords: ['home', '主页', '首页', 'dashboard'],
keywords: ['home', '主页', '首页', 'dashboard', '总览'],
},
// ==================== 文档库 ====================
{
id: 'docs-api',
title: '开发接口文档',
description: '查看 RESTful API 接口规格定义',
url: '/docs/api',
category: 'page',
keywords: ['api', 'docs', '文档', '接口', 'specification'],
},
{
id: 'docs-how-to-use',
title: '使用帮助文档',
description: '查看新手教程和集成示例',
url: '/docs/how-to-use',
titleKey: 'nav.usageDocs',
descriptionKey: 'search.items.usageDocsDesc',
url: 'https://openflare.fyrn.link/',
category: 'page',
keywords: ['docs', '文档', '使用', 'how to', 'tutorial', '教程', 'help'],
},
// ==================== 业务控制台 ====================
{
id: 'console-nodes',
titleKey: 'nav.nodes',
descriptionKey: 'search.items.nodesDesc',
url: '/nodes',
category: 'page',
keywords: [
'node',
'节点',
'边缘节点',
'中继',
'内网穿透',
'tunnel',
'服务器',
],
},
{
id: 'console-proxy-routes',
titleKey: 'nav.proxyRoutes',
descriptionKey: 'search.items.proxyRoutesDesc',
url: '/proxy-routes',
category: 'page',
keywords: ['route', '规则', '路由', '代理', '反向代理', 'proxy'],
},
{
id: 'console-websites',
titleKey: 'nav.websites',
descriptionKey: 'search.items.websitesDesc',
url: '/websites',
category: 'page',
keywords: ['website', 'domain', '网站', '域名', '站点'],
},
{
id: 'console-certificates',
titleKey: 'nav.certificates',
descriptionKey: 'search.items.certificatesDesc',
url: '/certificates',
category: 'page',
keywords: ['certificate', 'ssl', 'tls', '证书', 'https', '加密'],
},
{
id: 'console-dns-accounts',
titleKey: 'nav.dnsAccounts',
descriptionKey: 'search.items.dnsAccountsDesc',
url: '/dns-accounts',
category: 'page',
keywords: [
'dns',
'dns account',
'账号',
'域名解析',
'cloudflare',
'aliyun',
'tencent',
],
},
{
id: 'console-origins',
titleKey: 'nav.origins',
descriptionKey: 'search.items.originsDesc',
url: '/origins',
category: 'page',
keywords: ['origin', '源站', '后端', 'backend', '服务器', '负载均衡'],
},
{
id: 'console-responses',
titleKey: 'nav.responses',
descriptionKey: 'search.items.responsesDesc',
url: '/responses',
category: 'page',
keywords: [
'响应页面',
'错误页',
'联系页',
'源站',
'502',
'503',
'5xx',
'Service Worker',
'离线兜底',
'error page',
],
},
{
id: 'console-waf',
titleKey: 'nav.waf',
descriptionKey: 'search.items.wafDesc',
url: '/waf',
category: 'page',
keywords: ['waf', '防火墙', '安全', 'security', '拦截', '规则'],
},
{
id: 'console-ip-groups',
titleKey: 'nav.ipGroups',
descriptionKey: 'search.items.ipGroupsDesc',
url: '/ip-groups',
category: 'page',
keywords: ['ip', 'ip group', 'ip组', '黑名单', '白名单', '访问控制'],
},
{
id: 'console-rate-limits',
titleKey: 'nav.rateLimits',
descriptionKey: 'search.items.rateLimitsDesc',
url: '/rate-limits',
category: 'page',
keywords: [
'限流',
'rate limit',
'limit_conn',
'limit_rate',
'并发',
'带宽',
],
},
{
id: 'console-pages',
titleKey: 'nav.pages',
descriptionKey: 'search.items.pagesDesc',
url: '/pages',
category: 'page',
keywords: ['pages', '静态托管', 'cdn', '网站', '部署', 'static'],
},
{
id: 'console-config-versions',
titleKey: 'nav.configVersions',
descriptionKey: 'search.items.configVersionsDesc',
url: '/config-versions',
category: 'page',
keywords: ['version', 'config', '版本', '发布', '回滚', '对比', '部署'],
},
{
id: 'console-access-logs',
titleKey: 'nav.accessLogs',
descriptionKey: 'search.items.accessLogsDesc',
url: '/access-logs',
category: 'page',
keywords: ['log', 'logs', '访问日志', '分析', '流量', '请求'],
},
{
id: 'console-apply-logs',
titleKey: 'nav.applyLogs',
descriptionKey: 'search.items.applyLogsDesc',
url: '/apply-logs',
category: 'page',
keywords: [
'apply',
'log',
'logs',
'应用记录',
'配置下发',
'同步',
'部署历史',
],
},
{
id: 'console-performance',
titleKey: 'nav.performance',
descriptionKey: 'search.items.performanceDesc',
url: '/performance',
category: 'page',
keywords: ['performance', '性能', '调优', '优化', '参数', '连接', '超时'],
},
// ==================== 个人设置 ====================
{
id: 'settings',
title: '全局设置',
description: '配置应用个人偏好选项',
titleKey: 'search.items.globalSettings',
descriptionKey: 'search.items.settingsDesc',
url: '/settings',
category: 'setting',
keywords: ['settings', '设置', '偏好', 'preferences'],
},
{
id: 'settings-profile',
title: '我的资料',
description: '编辑昵称、头像和个人属性',
titleKey: 'user.profile',
descriptionKey: 'search.items.profileDesc',
url: '/settings/profile',
category: 'setting',
keywords: ['profile', '资料', '个人', '我的', '信息', 'avatar'],
},
{
id: 'settings-appearance',
title: '外观设置',
description: '配置系统显示主题(亮色/暗色)',
titleKey: 'search.items.appearance',
descriptionKey: 'search.items.appearanceDesc',
url: '/settings/appearance',
category: 'setting',
keywords: ['appearance', '外观', '主题', 'theme', 'dark', 'light'],
},
// ==================== 管理员 ====================
{
id: 'admin-settings',
title: '系统设置',
description: '管理系统登录注册与认证源配置 (管理员专属)',
titleKey: 'nav.adminSettings',
descriptionKey: 'search.items.adminSettingsDesc',
url: '/admin/settings',
category: 'admin',
keywords: [
@@ -117,24 +277,24 @@ export const searchData: SearchItem[] = [
},
{
id: 'admin-system',
title: '系统配置',
description: '动态修改平台核心运行时配置 (管理员专属)',
titleKey: 'nav.system',
descriptionKey: 'search.items.systemDesc',
url: '/admin/system',
category: 'admin',
keywords: ['admin', '管理员', '系统', '配置', 'system', 'configurations'],
},
{
id: 'admin-users',
title: '用户管理',
description: '管理平台注册用户的活跃状态 (管理员专属)',
titleKey: 'nav.users',
descriptionKey: 'search.items.usersDesc',
url: '/admin/users',
category: 'admin',
keywords: ['admin', '管理员', '用户', '管理', 'users', 'status'],
},
{
id: 'admin-tasks',
title: '异步任务管理',
description: '下发与排查后台异步定时任务 (管理员专属)',
titleKey: 'nav.tasks',
descriptionKey: 'search.items.tasksDesc',
url: '/admin/tasks',
category: 'admin',
keywords: [
@@ -147,17 +307,60 @@ export const searchData: SearchItem[] = [
'worker',
],
},
{
id: 'admin-files',
titleKey: 'nav.storage',
descriptionKey: 'search.items.storageDesc',
url: '/admin/files',
category: 'admin',
keywords: ['admin', '管理员', '存储', '文件', 'files', 'upload', 's3'],
},
{
id: 'admin-database',
titleKey: 'nav.database',
descriptionKey: 'search.items.databaseDesc',
url: '/admin/database',
category: 'admin',
keywords: ['admin', '管理员', '数据库', 'database', 'sql', 'query', 'gorm'],
},
{
id: 'admin-push',
titleKey: 'nav.push',
descriptionKey: 'search.items.pushDesc',
url: '/admin/push',
category: 'admin',
keywords: [
'admin',
'管理员',
'推送',
'通知',
'push',
'mail',
'telegram',
'lark',
],
},
{
id: 'admin-logs',
titleKey: 'nav.logs',
descriptionKey: 'search.items.logsDesc',
url: '/admin/logs',
category: 'admin',
keywords: ['admin', '管理员', '日志', 'logs', 'system log', 'terminal'],
},
];
/**
* 搜索功能
* @param query 搜索关键词
* @param isAdmin 是否为管理员
* @param t 文案解析函数(layout 命名空间)
* @returns 匹配的搜索结果
*/
export function searchItems(
query: string,
isAdmin: boolean = false,
t: (key: string) => string,
): SearchItem[] {
const trimmedQuery = query.trim();
@@ -166,11 +369,17 @@ export function searchItems(
? searchData
: searchData.filter((item) => item.category !== 'admin');
const resolved = filteredData.map((item) => ({
...item,
title: t(item.titleKey),
description: t(item.descriptionKey),
}));
if (!trimmedQuery) {
return filteredData;
return resolved;
}
return filteredData
return resolved
.map((item) => {
// 优先匹配标题
const titleMatch =