refactor(repo): replace legacy openflare-server with Wavelet rename

Delete the old monolithic openflare-server implementation and rename
Wavelet/ to openflare-server/ to complete the migration consolidation.
Update CI workflows, agent Dockerfiles, and deployment docs for the new
layout (frontend/, docker/Dockerfile).
This commit is contained in:
ryan
2026-06-19 11:29:17 +08:00
parent 46123d62ae
commit 88360350a0
1305 changed files with 40652 additions and 157629 deletions
+2 -2
View File
@@ -74,8 +74,8 @@ jobs:
id: build
uses: docker/build-push-action@v7
with:
context: .
file: ./openflare-server/Dockerfile
context: ./openflare-server
file: ./docker/Dockerfile
platforms: ${{ matrix.platform }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
build-args: |
+3 -3
View File
@@ -17,14 +17,14 @@ jobs:
env:
CI: ""
run: |
cd openflare-server/web
cd openflare-server/frontend
corepack enable
pnpm install --frozen-lockfile
pnpm build
pnpm build:embed
- name: Deploy 🚀
uses: JamesIves/github-pages-deploy-action@releases/v3
with:
ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }}
BRANCH: gh-pages # The branch the action should deploy to.
FOLDER: openflare-server/web/build # The folder the action should deploy.
FOLDER: openflare-server/frontend/out # The folder the action should deploy.
+26 -10
View File
@@ -69,26 +69,38 @@ jobs:
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.10.0
run_install: false
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: pnpm
cache-dependency-path: openflare-server/frontend/pnpm-lock.yaml
- name: Build Frontend
working-directory: openflare-server/frontend
env:
CI: ""
VERSION: ${{ needs.prepare.outputs.version }}
NEXT_PUBLIC_APP_VERSION: ${{ needs.prepare.outputs.version }}
run: |
cd openflare-server/web
corepack enable
pnpm install --frozen-lockfile
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build
pnpm build:embed
- name: Prepare embed directory
run: |
rm -rf openflare-server/internal/router/root/dist
cp -R openflare-server/frontend/out openflare-server/internal/router/root/dist
- name: Upload Frontend Artifact
uses: actions/upload-artifact@v4
with:
name: frontend-build
path: openflare-server/web/build
path: openflare-server/internal/router/root/dist
retention-days: 1
build-binaries:
@@ -127,14 +139,16 @@ jobs:
uses: actions/download-artifact@v4
with:
name: frontend-build
path: openflare-server/web/build
path: openflare-server/internal/router/root/dist
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
go-version-file: openflare-server/go.mod
cache-dependency-path: openflare-server/go.sum
- name: Build Server
working-directory: openflare-server
env:
CGO_ENABLED: 0
GOOS: ${{ matrix.goos }}
@@ -143,8 +157,10 @@ jobs:
VERSION: ${{ needs.prepare.outputs.version }}
run: |
go mod download
mkdir -p dist
go build -trimpath -ldflags "-s -w -X 'github.com/rain-kl/openflare/openflare-server/internal/common.Version=$VERSION'" -o "dist/$ASSET_NAME" ./openflare-server/cmd/server
mkdir -p ../dist
go build -trimpath -tags embed_frontend \
-ldflags "-s -w -X 'github.com/Rain-kl/Wavelet/internal/buildinfo.Version=$VERSION'" \
-o "../dist/$ASSET_NAME" ./main.go
- name: Upload Binary Artifact
uses: actions/upload-artifact@v4
+35
View File
@@ -0,0 +1,35 @@
{
"description": "dmux pane status hooks for Grok Build",
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node '/Users/ryan/DEV/Go/OpenFlare/.dmux/worktrees/dmux-2026-06-19-110554/.grok/hooks/dmux-status-hook.cjs'",
"timeout": 5,
"env": {
"DMUX_PANE_ID": "dmux-1781838355207",
"DMUX_TMUX_PANE_ID": "%28"
}
}
]
}
],
"Notification": [
{
"hooks": [
{
"type": "command",
"command": "node '/Users/ryan/DEV/Go/OpenFlare/.dmux/worktrees/dmux-2026-06-19-110554/.grok/hooks/dmux-status-hook.cjs'",
"timeout": 5,
"env": {
"DMUX_PANE_ID": "dmux-1781838355207",
"DMUX_TMUX_PANE_ID": "%28"
}
}
]
}
]
}
}
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env node
const fs = require('fs');
function normalizeHookEventName(value) {
const raw = String(value || '');
const normalized = raw.trim().toLowerCase().replace(/-/g, '_');
switch (normalized) {
case 'stop':
return 'Stop';
case 'notification':
return 'Notification';
case 'user_prompt_submit':
case 'userpromptsubmit':
return 'UserPromptSubmit';
case 'pre_tool_use':
case 'pretooluse':
return 'PreToolUse';
case 'post_tool_use':
case 'posttooluse':
return 'PostToolUse';
case 'post_tool_use_failure':
case 'posttoolusefailure':
return 'PostToolUseFailure';
case 'session_start':
case 'sessionstart':
return 'SessionStart';
case 'session_end':
case 'sessionend':
return 'SessionEnd';
default:
return raw;
}
}
function stringValue(...values) {
for (const value of values) {
if (typeof value === 'string' && value.trim()) {
return value;
}
}
return '';
}
let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
input += chunk;
});
process.stdin.on('end', () => {
let payload = {};
try {
payload = input.trim() ? JSON.parse(input) : {};
} catch (error) {
payload = { parse_error: String(error), raw: input };
}
const hookEventName = normalizeHookEventName(
payload.hookEventName || payload.hook_event_name || process.env.GROK_HOOK_EVENT
);
const sessionId = stringValue(
payload.sessionId,
payload.session_id,
process.env.GROK_SESSION_ID
);
const message = stringValue(
payload.lastAssistantMessage,
payload.last_assistant_message,
payload.message,
payload.notificationMessage,
payload.notification_message
);
const event = {
source: 'grok-status-hook',
dmuxPaneId: process.env.DMUX_PANE_ID || '',
tmuxPaneId: process.env.DMUX_TMUX_PANE_ID || '',
expectedDmuxPaneId: 'dmux-1781838355207',
expectedTmuxPaneId: '%28',
hookEventName,
sessionId,
turnId: stringValue(payload.turnId, payload.turn_id, sessionId),
lastAssistantMessage: message || null,
transcriptPath: stringValue(payload.transcriptPath, payload.transcript_path) || null,
cwd: stringValue(payload.cwd, payload.workspaceRoot, process.env.GROK_WORKSPACE_ROOT) || process.cwd(),
timestamp: Date.now()
};
if (event.dmuxPaneId !== event.expectedDmuxPaneId) {
process.exit(0);
}
try {
fs.writeFileSync('/Users/ryan/DEV/Go/OpenFlare/.dmux/worktrees/dmux-2026-06-19-110554/.grok/dmux/dmux-1781838355207.json', JSON.stringify(event, null, 2));
} catch (error) {
process.exit(0);
}
});
-30
View File
@@ -1,30 +0,0 @@
.git
.idea
.vscode
.DS_Store
Thumbs.db
config.yaml
.env
.env.*
bin/
build/
dist/
data/
logs/
uploads/
s3_cache/
frontend/node_modules/
frontend/.next/
frontend/out/
frontend/build/
frontend/disk/
frontend/.env
frontend/next-env.d.ts
frontend/*.tsbuildinfo
frontend/package-lock.json
internal/router/dist/
internal/router/root/dist/
-61
View File
@@ -1,61 +0,0 @@
# IDE
.idea/*
.idea
!.idea/icon.png
.vscode
.pnpm-store/
# logs
/logs/
*.log
# config
config.yaml
.env
# sqlite
*.db
*.db-journal
*.db-shm
*.db-wal
# frontend
frontend/build
frontend/disk
frontend/node_modules/*
frontend/.next/*
frontend/next-env.d.ts
frontend/package-lock.json
frontend/.env
.env.*
!.env.example
*.tsbuildinfo
# os
.DS_Store
Thumbs.db
# build
/build/
/bin/
/dist/
# go workspace
go.work
go.work.sum
main
# upload
uploads/*
s3_cache
/frontend/.next/
/data/
/internal/router/dist/
/frontend/out/
/.idea/
/uploads/
/*-source/
/.cache/
/internal/router/root/dist/
.dmux/
-17153
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-6
View File
@@ -1,6 +0,0 @@
// Copyright 2025 linux.do
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package common 提供跨模块共享的常量、错误定义和通用工具函数。
package common
@@ -1,57 +0,0 @@
// Copyright 2025 linux.do
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package response provides shared HTTP API response structures.
package response
import "github.com/gin-gonic/gin"
// Response 通用响应体
type Response[T any] struct {
ErrorMsg string `json:"error_msg"`
Data T `json:"data"`
}
// Any 用于 Swagger 文档的响应类型(非泛型)
// swag 不支持泛型,使用此类型替代 Response[T]
type Any struct {
ErrorMsg string `json:"error_msg" example:""`
Data interface{} `json:"data"`
}
// APIError 统一的 API 业务错误类型,可被全局错误处理中间件捕获
type APIError struct {
Code int
Msg string
}
func (e *APIError) Error() string {
return e.Msg
}
// NewError 实例化一个 APIError
func NewError(code int, msg string) *APIError {
return &APIError{Code: code, Msg: msg}
}
// AbortWithError 将 API 错误挂载到 Gin Context 并中断执行流
func AbortWithError(c *gin.Context, code int, msg string) {
_ = c.Error(NewError(code, msg))
c.Abort()
}
// OK 构造成功响应
func OK[T any](data T) Response[T] {
return Response[T]{Data: data}
}
// OKNil 构造成功响应(data 为 null)
func OKNil() Response[any] {
return Response[any]{Data: nil}
}
// Err 构造错误响应
func Err(msg string) Response[any] {
return Response[any]{ErrorMsg: msg, Data: nil}
}
-318
View File
@@ -1,318 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package model
import (
"context"
"errors"
"regexp"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/db"
"gorm.io/gorm"
)
// 认证源类型
const (
AuthSourceTypeOIDC = "oidc"
)
var authSourceNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$`)
// AuthSource 认证源实体
type AuthSource struct {
ID uint64 `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"uniqueIndex;size:80;not null"`
Type string `json:"type" gorm:"size:20;not null"`
DisplayName string `json:"display_name" gorm:"size:100"`
IsActive bool `json:"is_active" gorm:"index;not null;default:false"`
ClientID string `json:"client_id" gorm:"size:255"`
ClientSecret string `json:"-" gorm:"size:1024"`
OpenIDDiscoveryURL string `json:"openid_discovery_url" gorm:"column:openid_discovery_url;size:1024"`
Scopes string `json:"scopes" gorm:"size:255"`
IconURL string `json:"icon_url" gorm:"size:1024"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ClientSecretConfigured bool `json:"client_secret_configured" gorm:"-"`
}
// TableName 表名
func (AuthSource) TableName() string {
return "w_auth_sources"
}
// ExternalAccount 外部账号绑定实体
type ExternalAccount struct {
ID uint64 `json:"id" gorm:"primaryKey"`
AuthSourceID uint64 `json:"auth_source_id" gorm:"uniqueIndex:idx_external_accounts_source_external,priority:1;index"`
UserID uint64 `json:"user_id" gorm:"index;not null"`
ExternalID string `json:"external_id" gorm:"uniqueIndex:idx_external_accounts_source_external,priority:2;size:255;not null"`
ExternalUsername string `json:"external_username" gorm:"size:255"`
Email string `json:"email" gorm:"size:255"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName 表名
func (ExternalAccount) TableName() string {
return "w_external_accounts"
}
// ExternalAccountView 外部帐号绑定视图(脱敏展示用)
type ExternalAccountView struct {
ID uint64 `json:"id"`
AuthSourceID uint64 `json:"auth_source_id"`
AuthSourceName string `json:"auth_source_name"`
AuthSourceType string `json:"auth_source_type"`
AuthSourceLabel string `json:"auth_source_label"`
ExternalUsername string `json:"external_username"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
}
// Normalize 对认证源字段进行标准化处理
func (source *AuthSource) Normalize() {
source.Type = strings.ToLower(strings.TrimSpace(source.Type))
source.Name = strings.TrimSpace(source.Name)
source.DisplayName = strings.TrimSpace(source.DisplayName)
source.ClientID = strings.TrimSpace(source.ClientID)
source.ClientSecret = strings.TrimSpace(source.ClientSecret)
source.OpenIDDiscoveryURL = strings.TrimSpace(source.OpenIDDiscoveryURL)
source.Scopes = strings.TrimSpace(source.Scopes)
source.IconURL = strings.TrimSpace(source.IconURL)
if source.DisplayName == "" {
source.DisplayName = source.Name
}
if source.Type == AuthSourceTypeOIDC && source.Scopes == "" {
source.Scopes = "openid profile email"
}
}
// Validate 校验认证源字段合法性
func (source *AuthSource) Validate() error {
source.Normalize()
if source.Name == "" {
return errors.New(errAuthSourceNameRequired)
}
if !authSourceNamePattern.MatchString(source.Name) {
return errors.New(errAuthSourceNameInvalid)
}
if source.Type != AuthSourceTypeOIDC {
return errors.New(errAuthSourceTypeUnsupported)
}
if source.OpenIDDiscoveryURL == "" {
return errors.New(errAuthSourceDiscoveryURLRequired)
}
if source.IsActive && (source.ClientID == "" || source.ClientSecret == "") {
return errors.New(errAuthSourceClientCredentialsRequired)
}
return nil
}
// Sanitize 脱敏处理,将 ClientSecret 清空并设置 ClientSecretConfigured 标志
func (source *AuthSource) Sanitize() {
source.ClientSecretConfigured = source.ClientSecret != ""
source.ClientSecret = ""
}
// GetAuthSources 获取所有认证源(已脱敏)
func GetAuthSources(ctx context.Context) ([]AuthSource, error) {
var sources []AuthSource
if err := db.DB(ctx).Order("id asc").Find(&sources).Error; err != nil {
return nil, err
}
for i := range sources {
sources[i].Sanitize()
}
return sources, nil
}
// GetActiveAuthSources 获取所有已启用的认证源(已脱敏)
func GetActiveAuthSources(ctx context.Context) ([]AuthSource, error) {
var sources []AuthSource
if err := db.DB(ctx).Where("is_active = ?", true).Order("id asc").Find(&sources).Error; err != nil {
return nil, err
}
for i := range sources {
sources[i].Sanitize()
}
return sources, nil
}
// GetAuthSourceByID 根据 ID 获取认证源
func GetAuthSourceByID(ctx context.Context, id uint64) (*AuthSource, error) {
if id == 0 {
return nil, errors.New(errAuthSourceIDRequired)
}
var source AuthSource
if err := db.DB(ctx).First(&source, "id = ?", id).Error; err != nil {
return nil, err
}
source.ClientSecretConfigured = source.ClientSecret != ""
return &source, nil
}
// GetAuthSourceByName 根据名称获取认证源(名称比较不区分大小写)
func GetAuthSourceByName(ctx context.Context, name string) (*AuthSource, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, errors.New(errAuthSourceNameRequired)
}
var source AuthSource
if err := db.DB(ctx).First(&source, "LOWER(name) = LOWER(?)", name).Error; err != nil {
return nil, err
}
source.ClientSecretConfigured = source.ClientSecret != ""
return &source, nil
}
// CreateAuthSource 创建认证源
func CreateAuthSource(ctx context.Context, source *AuthSource) error {
if err := source.Validate(); err != nil {
return err
}
return db.DB(ctx).Create(source).Error
}
// UpdateAuthSource 更新认证源,keepSecret 为 true 时保留原密钥
func UpdateAuthSource(ctx context.Context, source *AuthSource, keepSecret bool) error {
if source.ID == 0 {
return errors.New(errAuthSourceIDRequired)
}
var current AuthSource
if err := db.DB(ctx).First(&current, "id = ?", source.ID).Error; err != nil {
return err
}
if keepSecret {
source.ClientSecret = current.ClientSecret
}
if err := source.Validate(); err != nil {
return err
}
return db.DB(ctx).Model(&current).Updates(map[string]any{
"name": source.Name,
"type": source.Type,
"display_name": source.DisplayName,
"is_active": source.IsActive,
"client_id": source.ClientID,
"client_secret": source.ClientSecret,
"openid_discovery_url": source.OpenIDDiscoveryURL,
"scopes": source.Scopes,
"icon_url": source.IconURL,
}).Error
}
// ToggleAuthSource 切换认证源启用状态
func ToggleAuthSource(ctx context.Context, id uint64, isActive bool) error {
source, err := GetAuthSourceByID(ctx, id)
if err != nil {
return err
}
source.IsActive = isActive
if err := source.Validate(); err != nil {
return err
}
return db.DB(ctx).Model(&AuthSource{}).Where("id = ?", id).Update("is_active", isActive).Error
}
// DeleteAuthSource 删除认证源及其关联的外部帐号绑定
func DeleteAuthSource(ctx context.Context, id uint64) error {
if id == 0 {
return errors.New(errAuthSourceIDRequired)
}
return db.DB(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("auth_source_id = ?", id).Delete(&ExternalAccount{}).Error; err != nil {
return err
}
return tx.Delete(&AuthSource{}, "id = ?", id).Error
})
}
// FindExternalAccount 查找外部帐号绑定记录
func FindExternalAccount(ctx context.Context, sourceID uint64, externalID string) (*ExternalAccount, error) {
var account ExternalAccount
if err := db.DB(ctx).Where("auth_source_id = ? AND external_id = ?", sourceID, externalID).First(&account).Error; err != nil {
return nil, err
}
return &account, nil
}
// BindExternalAccount 绑定外部帐号(已存在时更新用户名和邮箱)
func BindExternalAccount(ctx context.Context, account *ExternalAccount) error {
if account.UserID == 0 || strings.TrimSpace(account.ExternalID) == "" {
return errors.New(errExternalAccountBindingIncomplete)
}
account.ExternalID = strings.TrimSpace(account.ExternalID)
account.ExternalUsername = strings.TrimSpace(account.ExternalUsername)
account.Email = strings.TrimSpace(account.Email)
return db.DB(ctx).Transaction(func(tx *gorm.DB) error {
var current ExternalAccount
err := tx.Where("auth_source_id = ? AND external_id = ?", account.AuthSourceID, account.ExternalID).First(&current).Error
if err == nil {
if current.UserID != account.UserID {
return errors.New(errExternalAccountAlreadyBoundToAnother)
}
return tx.Model(&current).Updates(map[string]any{
"external_username": account.ExternalUsername,
"email": account.Email,
}).Error
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
return tx.Create(account).Error
})
}
// ListExternalAccountsByUserID 获取指定用户的所有外部帐号绑定视图
func ListExternalAccountsByUserID(ctx context.Context, userID uint64) ([]ExternalAccountView, error) {
if userID == 0 {
return nil, errors.New(errUserIDRequired)
}
var accounts []ExternalAccount
if err := db.DB(ctx).Where("user_id = ?", userID).Order("id asc").Find(&accounts).Error; err != nil {
return nil, err
}
views := make([]ExternalAccountView, 0, len(accounts))
for _, account := range accounts {
var name, sourceType, label string
if account.AuthSourceID == 0 {
name = "default"
sourceType = "oidc"
label = "历史认证源"
} else {
source, err := GetAuthSourceByID(ctx, account.AuthSourceID)
if err != nil {
continue
}
name = source.Name
sourceType = source.Type
label = source.DisplayName
if label == "" {
label = source.Name
}
}
views = append(views, ExternalAccountView{
ID: account.ID,
AuthSourceID: account.AuthSourceID,
AuthSourceName: name,
AuthSourceType: sourceType,
AuthSourceLabel: label,
ExternalUsername: account.ExternalUsername,
Email: account.Email,
CreatedAt: account.CreatedAt,
})
}
return views, nil
}
// DeleteExternalAccountForUser 删除指定用户的外部帐号绑定
func DeleteExternalAccountForUser(ctx context.Context, id uint64, userID uint64) error {
if id == 0 || userID == 0 {
return errors.New(errExternalAccountBindingIDRequired)
}
return db.DB(ctx).Where("id = ? AND user_id = ?", id, userID).Delete(&ExternalAccount{}).Error
}
-22
View File
@@ -1,22 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package main 是 OpenFlare 平台的程序入口
package main
import "github.com/Rain-kl/Wavelet/internal/cmd"
// @title OpenFlare API
// @version 1.0.0
// @description OpenFlare 平台后端 API,提供用户认证、系统配置、任务调度与边缘节点管理能力。
// @contact.name OpenFlare
// @contact.url https://github.com/Rain-kl/OpenFlare
// @license.name Apache 2.0
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
// @BasePath /
// @securityDefinitions.apikey SessionCookie
// @in cookie
// @name session
func main() {
cmd.Execute()
}
+2
View File
@@ -18,6 +18,7 @@ sidebar: false
### 移除
- 删除旧版 `openflare-server/` 单体实现(含 `web/` 前端);仓库内 `openflare-server/` 现指迁移后的 Wavelet 统一控制面。
- 移除 Wavelet About 界面相关代码:删除 `AboutService` 与 `GET /api/v1/d/about` 接口(页面从未实现,属冗余遗留)。
- 移除 Wavelet 中从旧系统迁移但未实装的全局 API / Web / 敏感接口限流选项(`GlobalApiRateLimit*`、`GlobalWebRateLimit*`、`CriticalRateLimit*`)及相关校验与数据库种子。
- 移除 `internal/apps/openflare/legacy/` 控制台兼容层、`compat/auth.go`(`OpenFlare-Token` JWT 桥接)及前端 `legacy-base.service.ts`;`openflare-server/web` 不再能对接当前 Wavelet 后端管理 API。
@@ -72,6 +73,7 @@ sidebar: false
### 变更
- 将 `Wavelet/` 目录重命名为 `openflare-server/`,完成 OpenFlare 后端与前端迁移后的仓库结构收敛;同步更新 Server Docker/Release 工作流与部署文档中的路径(`frontend/`、`docker/Dockerfile`)。
- Wavelet API 路径统一:管理端由 `/api/v1/openflare/*` 调整为 `/api/v1/d/*`;Agent/Relay/Tunnel 协议路由分别迁移至 `/api/v1/agent/*`、`/api/v1/relay/*`、`/api/v1/tunnel/*`(原 `/api/flared/*`)。同步更新 Wavelet 前端服务层与 `openflare-agent`、`openflare-relay`、`openflared` 客户端连接端点。
- Agent/Relay/Tunnel 协议 API 响应格式对齐 Wavelet `{error_msg, data}`:服务端移除 `compat` 包,业务错误改为 HTTP 4xx + `error_msg`;同步更新 `openflare-agent`、`openflare-relay`、`openflared` HTTP 客户端解析逻辑。
- OpenFlare 管理端权限模型对齐 Wavelet:取消旧系统 Admin/Root 三级角色区分,统一以 `user.IsAdmin` 为管理门槛;Access Token 访问敏感接口(Option、Update 等)须 `token_admin=true`;权限不足返回 HTTP 404 + `error_msg`,参数错误返回 HTTP 400 + `error_msg`(`apiutil.AdminMiddlewares` = `oauth.LoginRequired` + `admin.LoginAdminRequired`)。
+15 -57
View File
@@ -4,8 +4,6 @@
OpenFlare Server 是 Gin + GORM 单体控制面,负责管理端 UI、管理 API、Agent API、配置渲染、版本发布、数据存储与聚合查询。
> **迁移说明**:后端业务域正在迁入 [Wavelet](../plan/20260618-openflare-wavelet-backend-migration.md)。阶段一通过 Wavelet 的 `/api/*` legacy 兼容层联调旧前端;下文「Wavelet 后端」一节描述新后端的启动方式。
## 前置条件
| 项目 | 要求 |
@@ -15,17 +13,17 @@ OpenFlare Server 是 Gin + GORM 单体控制面,负责管理端 UI、管理 AP
| pnpm | 推荐通过 `corepack enable` 使用项目声明的 pnpm |
| 数据库 | SQLite 文件目录可写,或可访问的 PostgreSQL 实例 |
生产环境必须显式配置 `JWT_SECRET`,并优先使用 PostgreSQL。
生产环境必须配置 `session_secret`(或 `SESSION_SECRET`),并优先使用 PostgreSQL 与 Redis。
## 构建管理端前端
Go Server 会托管 `openflare-server/web/build` 中的静态产物。源码启动前先构建前端:
Go Server 会嵌入 `openflare-server/frontend/out` 静态产物。源码启动前先构建前端:
```bash
cd openflare-server/web
cd openflare-server/frontend
corepack enable
pnpm install
pnpm build
pnpm build:embed
```
常用前端检查:
@@ -40,10 +38,9 @@ pnpm test
```bash
cd openflare-server
export JWT_SECRET='replace-with-a-long-random-string'
export SQLITE_PATH='./openflare.db'
export LOG_LEVEL='info'
go run .
cp config.example.yaml config.yaml
# 编辑 config.yaml:设置 session_secret,并将 database.enabled 设为 false
go run . all
```
默认监听 `3000` 端口,访问:
@@ -56,15 +53,12 @@ http://localhost:3000
```bash
cd openflare-server
export JWT_SECRET='replace-with-a-long-random-string'
export DSN='postgres://openflare:secret@127.0.0.1:5432/openflare?sslmode=disable'
export LOG_LEVEL='info'
go run .
cp config.example.yaml config.yaml
# 编辑 config.yaml:设置 session_secret、database.* 与 redis.*
go run . all
```
`DSN` 设置后优先于 SQLite。`DSN` 与兼容旧命名的 `SQL_DSN` 同时存在时,优先使用 `DSN`。
如果目标 PostgreSQL 数据库为空且本地 `SQLITE_PATH` 文件存在,Server 启动阶段会尝试把 SQLite 数据迁移到 PostgreSQL,并在日志中输出迁移进度。
生产环境推荐分进程部署:`go run . api`、`go run . worker`、`go run . scheduler`。
## 使用 Docker 启动
@@ -173,13 +167,9 @@ go run . --port 3000 --log-dir ./logs
首次登录后请立即修改默认密码。
## Wavelet 后端(迁移中)
## 配置要点
阶段一将 OpenFlare 业务 API 运行在 Wavelet 框架内(`Wavelet/internal/apps/openflare/`),默认监听 `:3000`,与旧 Server 端口一致。旧前端仍使用 `openflare-server/web/build` 静态产物;API 请求指向 Wavelet 的 `/api/*` 兼容路由。
### 配置要点
复制 `Wavelet/config.example.yaml` 为 `config.yaml`,或使用 `Wavelet/.env.example` 中的环境变量。关键默认值:
复制 `openflare-server/config.example.yaml` 为 `config.yaml`,或使用 `openflare-server/.env.example` 中的环境变量。关键默认值:
| 项 | 值 |
| --- | --- |
@@ -189,46 +179,14 @@ go run . --port 3000 --log-dir ./logs
| `application_name` | `openflare-server` |
| Redis 键前缀 | `openflare:` |
生产环境需启用 PostgreSQL(`database.enabled: true` 或 `DB_ENABLED=true`),并配置 Redis。
### 启动命令
```bash
cd Wavelet
# 开发:API + Worker + Scheduler 合一
go run . all
# 生产:分进程部署
go run . api # HTTP API + OpenFlare 定时任务
go run . worker # Wavelet Asynq 异步任务
go run . scheduler # Wavelet Asynq 定时触发
```
也可使用 `docker compose up`(见 `Wavelet/docker-compose.yml`)拉起 PostgreSQL、Redis 与 Wavelet 服务。
### OpenFlare 定时任务
OpenFlare 业务定时任务集中在 `internal/apps/openflare/tasks/`,通过 `robfig/cron` 在 **API 进程**内运行(非 Asynq):
| 任务 | 调度 | 说明 |
| --- | --- | --- |
| 可观测数据自动清理 | 每日 03:00 | 受 Option `DatabaseAutoCleanupEnabled` 控制 |
| WAF IP 组同步 | 每 5 分钟 | 向在线 Agent 下发 IP 组变更 |
| UptimeKuma 同步 | 每分钟检查间隔 | 按 Option 配置的间隔触发 |
| SSL 自动续期 | 每日 00:00 | ACME 证书续期 |
API 启动后日志中应出现 `[OpenFlareTasks] registered cron job` 行。`worker` 与 `scheduler` 进程不运行上述 cron。
也可使用 `docker compose up`(见 `openflare-server/docker-compose.yml`)拉起 PostgreSQL、Redis 与 Server。
### 验证
```bash
cd Wavelet
cd openflare-server
go build ./...
go test ./internal/apps/openflare/... -count=1
# 健康检查
curl http://127.0.0.1:3000/api/status
```
更多迁移进度与接手说明见 [AI 接手文档](../plan/handover-openflare-backend-migration.md)。
+1 -1
View File
@@ -67,7 +67,7 @@ OpenFlare 适合需要统一管理多台 OpenResty 代理节点的团队,具
| 路径 | 职责 |
| ---------------------- | ---------------------------------------------------- |
| `openflare-server` | Gin + GORM + SQLite/PostgreSQL 单体控制面 |
| `openflare-server/web` | Next.js 15 App Router 管理端前端,由 Go Server 托管 |
| `openflare-server/frontend` | Next.js App Router 管理端前端,由 Go Server 嵌入托管 |
| `pkg` | 跨组件复用的协议类型与通用工具包 |
| `openflare-agent` | Go 单体 Agent,运行在节点侧 |
| `openflare-relay` | Tunnel 中继代理,运行在公网边缘管理 frps 进程 |
+7 -9
View File
@@ -8,10 +8,8 @@
```bash
cd openflare-server
export JWT_SECRET='replace-with-random-string'
export SQLITE_PATH='./openflare.db'
export LOG_LEVEL='info'
go run .
cp config.example.yaml config.yaml
go run . all
```
指定监听端口与日志目录:
@@ -32,7 +30,7 @@ GOCACHE=/tmp/openflare-go-cache go test ./...
开发:
```bash
cd openflare-server/web
cd openflare-server/frontend
pnpm install
pnpm dev
```
@@ -40,14 +38,14 @@ pnpm dev
构建静态产物:
```bash
cd openflare-server/web
pnpm build
cd openflare-server/frontend
pnpm build:embed
```
检查:
```bash
cd openflare-server/web
cd openflare-server/frontend
pnpm lint
pnpm typecheck
pnpm test
@@ -146,5 +144,5 @@ pnpm dev
```bash
cd docs
pnpm build
pnpm build:embed
```
-1
View File
@@ -16,7 +16,6 @@ COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY openflare-server ./openflare-server
COPY openflare-agent ./openflare-agent
COPY pkg ./pkg
RUN --mount=type=cache,target=/go/pkg/mod \
-1
View File
@@ -10,7 +10,6 @@ COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY openflare-server ./openflare-server
COPY openflare-relay ./openflare-relay
COPY pkg ./pkg
RUN --mount=type=cache,target=/go/pkg/mod \
+26 -12
View File
@@ -1,16 +1,30 @@
.git
.github
node_modules
web/node_modules
web/.next
web/build
web/out
dist
tmp
logs
upload
*.log
.idea
.vscode
.DS_Store
Thumbs.db
config.yaml
.env
.env.*
docker-compose*.yml
bin/
build/
dist/
data/
logs/
uploads/
s3_cache/
frontend/node_modules/
frontend/.next/
frontend/out/
frontend/build/
frontend/disk/
frontend/.env
frontend/next-env.d.ts
frontend/*.tsbuildinfo
frontend/package-lock.json
internal/router/dist/
internal/router/root/dist/
+60 -1
View File
@@ -1,2 +1,61 @@
# IDE
.idea/*
.idea
!.idea/icon.png
.vscode
.pnpm-store/
# logs
/logs/
*.log
# config
config.yaml
.env
# sqlite
*.db
*.db-journal
*.db-shm
*.db-wal
# frontend
frontend/build
frontend/disk
frontend/node_modules/*
frontend/.next/*
frontend/next-env.d.ts
frontend/package-lock.json
frontend/.env
.env.*
!.env.example
*.tsbuildinfo
# os
.DS_Store
Thumbs.db
# build
/build/
/bin/
/dist/
# go workspace
go.work
go.work.sum
main
# upload
uploads/*
s3_cache
/frontend/.next/
/data/
/postgres-data/
/internal/router/dist/
/frontend/out/
/.idea/
/uploads/
/*-source/
/.cache/
/internal/router/root/dist/
.dmux/
@@ -250,7 +250,7 @@ func doSomething(c *gin.Context) { response.AbortBadRequest(c, "...") }
路由与模块:
- 仅在 `internal/router/router.go` 中作为统一高层入口进行路由分发委派,不允许在 `router.go` 中直接挂载业务 Handler。
- 关于所有的路由归属划分、接口开发隔离防线以及详细的注册和开发步骤,请直接阅读并严格遵循 [new-api](file:///Users/ryan/DEV/Go/Wavelet/.claude/skills/new-api/SKILL.md) 技能。
- 关于所有的路由归属划分、接口开发隔离防线以及详细的注册和开发步骤,请直接阅读并严格遵循 [new-api](file:///Users/ryan/DEV/Go/OpenFlare/openflare-server/.claude/skills/new-api/SKILL.md) 技能。
应用装配与跨模块集成:
@@ -282,7 +282,7 @@ func doSomething(c *gin.Context) { response.AbortBadRequest(c, "...") }
在进行任何 Next.js 工作之前,请在 `node_modules/next/dist/docs/` 中找到并阅读相关文档。您的训练数据已过时 —— 这些文档是唯一的真理来源。
请直接查看并参考项目提供的示例和 Demo 代码:[frontend/app/(main)/admin/demo](file:///Users/ryan/DEV/Go/Wavelet/frontend/app/(main)/admin/demo)。
请直接查看并参考项目提供的示例和 Demo 代码:[frontend/app/(main)/admin/demo](file:///Users/ryan/DEV/Go/OpenFlare/openflare-server/frontend/app/(main)/admin/demo)。
样式规范:
-39
View File
@@ -1,39 +0,0 @@
# syntax=docker/dockerfile:1.7
ARG VERSION=dev
FROM node:20 AS web-builder
ARG VERSION
WORKDIR /build
RUN corepack enable
COPY openflare-server/web/package.json openflare-server/web/pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
COPY openflare-server/web ./
RUN --mount=type=cache,id=next-cache,target=/build/.next/cache \
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build
FROM golang:1.25 AS go-builder
ARG VERSION
ENV GO111MODULE=on \
CGO_ENABLED=0 \
GOOS=linux
WORKDIR /build
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY openflare-server ./openflare-server
COPY pkg ./pkg
COPY --from=web-builder /build/build ./openflare-server/web/build
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -trimpath \
-ldflags "-s -w -X 'github.com/rain-kl/openflare/openflare-server/internal/common.Version=$VERSION'" \
-o openflare ./openflare-server/cmd/server
FROM alpine:latest
RUN apk add --no-cache ca-certificates tzdata \
&& update-ca-certificates 2>/dev/null || true
ENV PORT=3000
COPY --from=go-builder /build/openflare /openflare
EXPOSE 3000
WORKDIR /data
ENTRYPOINT ["/openflare"]
-562
View File
@@ -1,562 +0,0 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
"github.com/rain-kl/openflare/openflare-server/internal/common"
"github.com/rain-kl/openflare/openflare-server/internal/model"
)
const retentionDays = 90
func main() {
dsn := flag.String("dsn", envOr("DSN", "postgres://openflare:replace-with-strong-password@192.168.107.2:5432/openflare?sslmode=disable"), "PostgreSQL DSN")
records := flag.Int("records", 1_000_000, "number of access log rows to seed (0 = skip seeding)")
seedNode := flag.String("node-id", "bench-node", "node_id used for seeded rows")
iterations := flag.Int("iterations", 10, "benchmark iterations per scenario")
warmup := flag.Int("warmup", 2, "warmup iterations per scenario")
page := flag.Int("page", 0, "page index for list benchmark")
pageSize := flag.Int("page-size", 20, "page size for list benchmark")
reset := flag.Bool("reset", false, "truncate node_access_logs shard tables before seeding")
legacyIterations := flag.Int("legacy-iterations", 1, "iterations for legacy full-scan scenario (can be very slow)")
skipLegacy := flag.Bool("skip-legacy", false, "skip legacy full-scan scenario")
verifyOnly := flag.Bool("verify-only", false, "verify query correctness and exit")
flag.Parse()
common.SQLDSN = *dsn
common.SQLitePath = filepath.Join(os.TempDir(), "openflare-accesslogbench-missing.sqlite")
if err := initBenchDB(*dsn); err != nil {
log.Fatalf("init db: %v", err)
}
fmt.Println("=== OpenFlare node_access_logs benchmark (PostgreSQL) ===")
fmt.Printf("DSN: %s\n", redactDSN(*dsn))
fmt.Printf("GOMAXPROCS=%d\n", runtime.GOMAXPROCS(0))
if *reset {
if err := truncateAccessLogs(); err != nil {
log.Fatalf("truncate access logs: %v", err)
}
fmt.Println("truncated node_access_logs shard tables")
}
if *records > 0 {
existing, err := countAllAccessLogs()
if err != nil {
log.Fatalf("count existing rows: %v", err)
}
if existing >= int64(*records) {
fmt.Printf("existing rows=%d >= target=%d, skip seeding\n", existing, *records)
} else {
missing := *records - int(existing)
fmt.Printf("seeding %d rows (existing=%d)...\n", missing, existing)
if err := seedAccessLogs(*seedNode, missing); err != nil {
log.Fatalf("seed access logs: %v", err)
}
}
}
total, err := countAllAccessLogs()
if err != nil {
log.Fatalf("count rows after seed: %v", err)
}
fmt.Printf("total rows across shards: %d\n\n", total)
since := time.Now().UTC().Add(-retentionDays * 24 * time.Hour)
if err := verifyQueryCorrectness(since, total); err != nil {
log.Fatalf("correctness verification failed: %v", err)
}
fmt.Println("correctness verification: PASS")
if *verifyOnly {
return
}
query := model.NodeAccessLogQuery{
Since: since,
Page: *page,
PageSize: *pageSize,
SortBy: "logged_at",
SortOrder: "desc",
}
scenarios := []scenario{
{
name: "paginated_list",
run: func() error {
_, err := model.ListNodeAccessLogs(query)
return err
},
},
{
name: "sql_count",
run: func() error {
_, _, err := model.CountNodeAccessLogs(query)
return err
},
},
{
name: "api_list+count",
run: func() error {
if _, err := model.ListNodeAccessLogs(query); err != nil {
return err
}
_, _, err := model.CountNodeAccessLogs(query)
return err
},
},
{
name: "fullscan_list_legacy",
run: func() error {
_, err := fullScanList(query)
return err
},
},
}
if err := printExplainPlans(since); err != nil {
log.Printf("warn: explain analyze failed: %v", err)
}
for _, item := range scenarios {
if item.name == "fullscan_list_legacy" && *skipLegacy {
fmt.Println("[fullscan_list_legacy] skipped")
continue
}
iterationCount := *iterations
if item.name == "fullscan_list_legacy" {
iterationCount = *legacyIterations
}
result, err := runScenario(item, *warmup, iterationCount)
if err != nil {
log.Fatalf("scenario %s failed: %v", item.name, err)
}
printResult(result)
}
}
type scenario struct {
name string
run func() error
}
type benchResult struct {
name string
iterations int
latencies []time.Duration
allocBytes []uint64
heapInUse []uint64
maxHeapInUse uint64
}
func runScenario(item scenario, warmup int, iterations int) (*benchResult, error) {
for range warmup {
if err := item.run(); err != nil {
return nil, err
}
}
result := &benchResult{
name: item.name,
iterations: iterations,
}
var peakHeap uint64
for range iterations {
runtime.GC()
var before, after runtime.MemStats
runtime.ReadMemStats(&before)
start := time.Now()
if err := item.run(); err != nil {
return nil, err
}
elapsed := time.Since(start)
runtime.ReadMemStats(&after)
result.latencies = append(result.latencies, elapsed)
result.allocBytes = append(result.allocBytes, after.TotalAlloc-before.TotalAlloc)
result.heapInUse = append(result.heapInUse, after.HeapInuse)
if after.HeapInuse > peakHeap {
peakHeap = after.HeapInuse
}
}
result.maxHeapInUse = peakHeap
return result, nil
}
func printResult(result *benchResult) {
latencies := append([]time.Duration(nil), result.latencies...)
sort.Slice(latencies, func(i, j int) bool { return latencies[i] < latencies[j] })
var allocSum uint64
var heapSum uint64
for index := range result.allocBytes {
allocSum += result.allocBytes[index]
heapSum += result.heapInUse[index]
}
fmt.Printf("[%s] iterations=%d\n", result.name, result.iterations)
fmt.Printf(" latency: min=%s avg=%s p50=%s p95=%s max=%s\n",
minDuration(latencies),
avgDuration(latencies),
percentile(latencies, 50),
percentile(latencies, 95),
maxDuration(latencies),
)
fmt.Printf(" alloc/op: avg=%s peak_heap=%s\n",
humanBytes(allocSum/uint64(len(result.allocBytes))),
humanBytes(result.maxHeapInUse),
)
fmt.Printf(" heap_inuse/op: avg=%s\n\n", humanBytes(heapSum/uint64(len(result.heapInUse))))
}
func seedAccessLogs(nodeID string, total int) error {
started := time.Now()
perShard := total / 10
remainder := total % 10
seeded := 0
now := time.Now().UTC()
for shard := range 10 {
rows := perShard
if shard < remainder {
rows++
}
if rows == 0 {
continue
}
table := fmt.Sprintf("node_access_logs_%02d", shard)
sql := fmt.Sprintf(`
INSERT INTO %s (id, node_id, logged_at, remote_addr, region, host, path, status_code, created_at)
SELECT
(gs * 10 + %d)::bigint AS id,
$1 AS node_id,
$2::timestamptz - (gs || ' minutes')::interval AS logged_at,
('203.0.' || ((gs / 256) %% 256)::text || '.' || (gs %% 256)::text) AS remote_addr,
'Benchland' AS region,
('host-' || (gs %% 200)::text || '.example.com') AS host,
('/api/v1/resource/' || gs::text) AS path,
(200 + (gs %% 4))::bigint AS status_code,
$2::timestamptz AS created_at
FROM generate_series(0, $3 - 1) AS gs
`, table, shard)
if err := model.DB.Exec(sql, nodeID, now, rows).Error; err != nil {
return err
}
seeded += rows
elapsed := time.Since(started)
rate := float64(seeded) / elapsed.Seconds()
fmt.Printf(" seeded %d/%d rows (%.0f rows/s, elapsed %s)\n", seeded, total, rate, elapsed.Round(time.Millisecond))
}
return nil
}
func truncateAccessLogs() error {
for _, table := range observabilityShardTables() {
if err := model.DB.Exec("TRUNCATE TABLE " + table).Error; err != nil {
return err
}
}
return nil
}
func countAllAccessLogs() (int64, error) {
var total int64
for _, table := range observabilityShardTables() {
var count int64
if err := model.DB.Table(table).Count(&count).Error; err != nil {
return 0, err
}
total += count
}
return total, nil
}
func observabilityShardTables() []string {
tables := make([]string, 0, 10)
for index := range 10 {
tables = append(tables, fmt.Sprintf("node_access_logs_%02d", index))
}
return tables
}
func verifyQueryCorrectness(since time.Time, tableTotal int64) error {
fmt.Println("=== correctness verification ===")
baseQuery := model.NodeAccessLogQuery{
Since: since,
SortBy: "logged_at",
SortOrder: "desc",
}
reference, err := fullScanList(baseQuery)
if err != nil {
return fmt.Errorf("reference full scan failed: %w", err)
}
if int64(len(reference)) != tableTotal {
return fmt.Errorf("reference row count %d != table total %d", len(reference), tableTotal)
}
fmt.Printf(" reference rows in retention window: %d\n", len(reference))
totalRecords, totalIPs, err := model.CountNodeAccessLogs(baseQuery)
if err != nil {
return fmt.Errorf("CountNodeAccessLogs failed: %w", err)
}
if totalRecords != int64(len(reference)) {
return fmt.Errorf("total_records=%d want reference=%d", totalRecords, len(reference))
}
referenceIPs := countUniqueIPs(reference)
if totalIPs != referenceIPs {
return fmt.Errorf("total_ip=%d want reference=%d", totalIPs, referenceIPs)
}
fmt.Printf(" count totals: total_record=%d total_ip=%d\n", totalRecords, totalIPs)
pages := []struct {
page int
pageSize int
}{
{0, 20},
{1, 20},
{49, 20},
{100, 50},
}
for _, item := range pages {
query := baseQuery
query.Page = item.page
query.PageSize = item.pageSize
got, err := model.ListNodeAccessLogs(query)
if err != nil {
return fmt.Errorf("ListNodeAccessLogs page=%d size=%d failed: %w", item.page, item.pageSize, err)
}
start := item.page * item.pageSize
end := start + item.pageSize
if start >= len(reference) {
if len(got) != 0 {
return fmt.Errorf("page=%d size=%d expected empty got %d", item.page, item.pageSize, len(got))
}
continue
}
if end > len(reference) {
end = len(reference)
}
want := reference[start:end]
if !accessLogsEqual(got, want) {
return fmt.Errorf("page=%d size=%d content mismatch (got %d want %d rows)", item.page, item.pageSize, len(got), len(want))
}
fmt.Printf(" page=%d size=%d: %d rows match reference\n", item.page, item.pageSize, len(got))
}
filtered := model.NodeAccessLogQuery{
NodeID: "bench-node",
Since: since,
SortBy: "status_code",
SortOrder: "asc",
Page: 2,
PageSize: 15,
}
filteredReference, err := fullScanList(filtered)
if err != nil {
return fmt.Errorf("filtered reference failed: %w", err)
}
filteredRows, err := model.ListNodeAccessLogs(filtered)
if err != nil {
return fmt.Errorf("filtered ListNodeAccessLogs failed: %w", err)
}
start := filtered.Page * filtered.PageSize
end := start + filtered.PageSize
if end > len(filteredReference) {
end = len(filteredReference)
}
if start >= len(filteredReference) {
start = len(filteredReference)
}
if !accessLogsEqual(filteredRows, filteredReference[start:end]) {
return fmt.Errorf("filtered page content mismatch")
}
filteredTotal, filteredIPs, err := model.CountNodeAccessLogs(filtered)
if err != nil {
return fmt.Errorf("filtered CountNodeAccessLogs failed: %w", err)
}
if filteredTotal != int64(len(filteredReference)) {
return fmt.Errorf("filtered total_records=%d want %d", filteredTotal, len(filteredReference))
}
if filteredIPs != countUniqueIPs(filteredReference) {
return fmt.Errorf("filtered total_ip=%d want %d", filteredIPs, countUniqueIPs(filteredReference))
}
fmt.Printf(" filtered node_id=bench-node page=2 size=15: match (total_record=%d total_ip=%d)\n", filteredTotal, filteredIPs)
return nil
}
func countUniqueIPs(logs []*model.NodeAccessLog) int64 {
ips := make(map[string]struct{})
for _, item := range logs {
if item == nil {
continue
}
if trimmed := strings.TrimSpace(item.RemoteAddr); trimmed != "" {
ips[trimmed] = struct{}{}
}
}
return int64(len(ips))
}
func accessLogsEqual(left []*model.NodeAccessLog, right []*model.NodeAccessLog) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] == nil || right[index] == nil {
if left[index] != right[index] {
return false
}
continue
}
if left[index].ID != right[index].ID ||
left[index].NodeID != right[index].NodeID ||
!left[index].LoggedAt.Equal(right[index].LoggedAt) ||
left[index].RemoteAddr != right[index].RemoteAddr ||
left[index].Host != right[index].Host ||
left[index].Path != right[index].Path ||
left[index].StatusCode != right[index].StatusCode {
return false
}
}
return true
}
func printExplainPlans(since time.Time) error {
fmt.Println("=== PostgreSQL EXPLAIN (one shard sample: node_access_logs_00) ===")
queries := []struct {
name string
sql string
}{
{
name: "paginated_list",
sql: "EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM node_access_logs_00 WHERE logged_at >= $1 ORDER BY logged_at DESC, id DESC LIMIT 20",
},
{
name: "count_rows",
sql: "EXPLAIN (ANALYZE, BUFFERS) SELECT COUNT(*) FROM node_access_logs_00 WHERE logged_at >= $1",
},
{
name: "distinct_ip_union_all",
sql: `EXPLAIN (ANALYZE, BUFFERS) SELECT COUNT(*) FROM (
SELECT remote_addr FROM (
SELECT TRIM(remote_addr) AS remote_addr FROM node_access_logs_00 WHERE logged_at >= $1 AND remote_addr <> ''
UNION ALL
SELECT TRIM(remote_addr) AS remote_addr FROM node_access_logs_01 WHERE logged_at >= $1 AND remote_addr <> ''
) AS all_ips GROUP BY remote_addr
) AS ips`,
},
}
for _, item := range queries {
rows, err := model.DB.Raw(item.sql, since).Rows()
if err != nil {
return err
}
fmt.Printf("-- %s\n", item.name)
for rows.Next() {
var line string
if err := rows.Scan(&line); err != nil {
rows.Close()
return err
}
fmt.Println(line)
}
rows.Close()
fmt.Println()
}
return nil
}
func fullScanList(query model.NodeAccessLogQuery) ([]*model.NodeAccessLog, error) {
legacy := query
legacy.PageSize = 0
return model.ListNodeAccessLogs(legacy)
}
func initBenchDB(dsn string) error {
return model.InitBenchmarkDB(dsn)
}
func envOr(key string, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
func redactDSN(dsn string) string {
if at := strings.Index(dsn, "@"); at > 0 {
schemeEnd := strings.Index(dsn, "://")
if schemeEnd >= 0 {
return dsn[:schemeEnd+3] + "***@" + dsn[at+1:]
}
}
return dsn
}
func minDuration(values []time.Duration) time.Duration {
if len(values) == 0 {
return 0
}
return values[0]
}
func maxDuration(values []time.Duration) time.Duration {
if len(values) == 0 {
return 0
}
return values[len(values)-1]
}
func avgDuration(values []time.Duration) time.Duration {
if len(values) == 0 {
return 0
}
var sum time.Duration
for _, value := range values {
sum += value
}
return sum / time.Duration(len(values))
}
func percentile(values []time.Duration, p int) time.Duration {
if len(values) == 0 {
return 0
}
if p <= 0 {
return values[0]
}
if p >= 100 {
return values[len(values)-1]
}
index := (len(values)*p + 99) / 100
if index <= 0 {
index = 1
}
if index > len(values) {
index = len(values)
}
return values[index-1]
}
func humanBytes(value uint64) string {
const unit = 1024
if value < unit {
return fmt.Sprintf("%d B", value)
}
div, exp := uint64(unit), 0
for n := value / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(value)/float64(div), "KMGTPE"[exp])
}
-7
View File
@@ -1,7 +0,0 @@
package main
import server "github.com/rain-kl/openflare/openflare-server"
func main() {
server.Run()
}
-35
View File
@@ -1,35 +0,0 @@
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: openflare
POSTGRES_USER: openflare
POSTGRES_PASSWORD: replace-with-strong-password
volumes:
- ./postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U openflare -d openflare"]
interval: 10s
timeout: 5s
retries: 5
openflare:
build:
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
ports:
- "3000:3000"
environment:
SESSION_SECRET: replace-with-random-string
SQLITE_PATH: /data/openflare.db
DSN: postgres://openflare:replace-with-strong-password@postgres:5432/openflare?sslmode=disable
GIN_MODE: release
LOG_LEVEL: info
volumes:
- ./openflare-data:/data
+15167 -1803
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More