mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
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:
@@ -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: |
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Executable
+97
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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/
|
||||
@@ -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
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
@@ -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}
|
||||
}
|
||||
@@ -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(¤t, "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(¤t).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(¤t).Error
|
||||
if err == nil {
|
||||
if current.UserID != account.UserID {
|
||||
return errors.New(errExternalAccountAlreadyBoundToAnother)
|
||||
}
|
||||
return tx.Model(¤t).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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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)。
|
||||
|
||||
@@ -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 进程 |
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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/
|
||||
|
||||
Vendored
Vendored
Vendored
Vendored
Vendored
Vendored
@@ -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)。
|
||||
|
||||
样式规范:
|
||||
|
||||
@@ -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"]
|
||||
@@ -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])
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package main
|
||||
|
||||
import server "github.com/rain-kl/openflare/openflare-server"
|
||||
|
||||
func main() {
|
||||
server.Run()
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+15166
-1806
File diff suppressed because it is too large
Load Diff
+9690
-1460
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
Reference in New Issue
Block a user