mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-06 07:36:37 +08:00
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:
@@ -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> {
|
||||
|
||||
@@ -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 校验答案
|
||||
|
||||
@@ -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;
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,8 +4,8 @@ import type {
|
||||
CreateUserRequest,
|
||||
ListUsersRequest,
|
||||
ListUsersResponse,
|
||||
UpdateUserStatusRequest,
|
||||
UpdateUserRequest,
|
||||
UpdateUserStatusRequest,
|
||||
} from './types';
|
||||
|
||||
export class AdminUserService extends BaseService {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user