feat: 添加同步进度显示和中文界面支持
refactor: 重构同步引擎以支持进度跟踪 style: 更新前端界面为中文 docs: 更新README为中文文档
This commit is contained in:
181
README.md
181
README.md
@@ -1,60 +1,161 @@
|
|||||||
# GitM - Gitea Repository Sync Tool
|
# GitM - Gitea 仓库镜像同步工具
|
||||||
|
|
||||||
Cross-platform tool to synchronize all repositories from multiple Gitea servers to local storage.
|
将多个 Gitea 服务器的仓库自动镜像同步到本地存储,支持定时同步和手动同步。
|
||||||
|
|
||||||
## Features
|
## 功能特性
|
||||||
|
|
||||||
- Single binary deployment (frontend embedded)
|
- **单文件部署** - 前端内嵌到 Go 二进制,无需额外依赖
|
||||||
- Web UI for management (Vue 3 + Element Plus)
|
- **Web 管理界面** - Vue 3 + Element Plus,中文界面
|
||||||
- SQLite database
|
- **多服务器管理** - 同时管理多个 Gitea 服务器
|
||||||
- JWT authentication
|
- **仓库自动发现** - 一键发现服务器上的所有仓库
|
||||||
- Scheduled and manual sync
|
- **镜像同步** - 使用 `git clone --mirror` 完整镜像,支持 Token 认证
|
||||||
- Cross-platform (Windows, Linux)
|
- **实时进度** - 同步过程实时展示当前克隆/同步的仓库及进度
|
||||||
|
- **定时同步** - 可配置每个服务器的自动同步间隔
|
||||||
|
- **SQLite 存储** - 轻量级数据库,无需额外安装
|
||||||
|
|
||||||
## Build
|
## 快速开始
|
||||||
|
|
||||||
|
### 前置要求
|
||||||
|
|
||||||
|
- Go 1.21+(需要 CGO 支持 SQLite)
|
||||||
|
- GCC(Windows 下需安装 MinGW-w64)
|
||||||
|
- Node.js 18+(仅开发时需要)
|
||||||
|
|
||||||
|
### 安装与运行
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build binary with embedded frontend
|
# 1. 初始化(设置管理员密码)
|
||||||
go build -o bin/gitm .
|
$env:CGO_ENABLED="1"; go run main.go --init
|
||||||
|
|
||||||
# Or with CGO for SQLite support
|
# 2. 启动服务(默认监听 :9000)
|
||||||
CGO_ENABLED=1 go build -o bin/gitm .
|
$env:CGO_ENABLED="1"; go run main.go
|
||||||
```
|
```
|
||||||
|
|
||||||
## Run
|
启动后浏览器访问 `http://localhost:9000`
|
||||||
|
|
||||||
|
### 使用流程
|
||||||
|
|
||||||
|
1. **登录** - 使用初始化时设置的密码登录
|
||||||
|
2. **添加服务器** - 填写 Gitea 服务器地址和 API Token
|
||||||
|
3. **测试连接** - 验证服务器和 Token 是否可用
|
||||||
|
4. **发现仓库** - 扫描服务器上的所有仓库
|
||||||
|
5. **同步** - 将仓库镜像克隆到本地,支持手动和定时同步
|
||||||
|
|
||||||
|
## 打包部署
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# First-time initialization (set admin password)
|
# 构建前端
|
||||||
./bin/gitm --init
|
cd web
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
cd ..
|
||||||
|
|
||||||
# Start server (default :9000)
|
# 打包为单文件 exe
|
||||||
./bin/gitm
|
$env:CGO_ENABLED="1"; go build -o gitm.exe
|
||||||
|
|
||||||
# Custom address
|
|
||||||
./bin/gitm --addr :9090
|
|
||||||
|
|
||||||
# Custom data directory
|
|
||||||
./bin/gitm --data /path/to/data
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
打包后的 `gitm.exe` 包含前后端,直接运行即可。
|
||||||
|
|
||||||
1. Initialize: `./bin/gitm --init` -- sets admin password
|
### 运行
|
||||||
2. Start: `./bin/gitm` -- starts web server
|
|
||||||
3. Open browser to `http://localhost:9000`
|
|
||||||
4. Login and add Gitea servers
|
|
||||||
5. Discover repos and sync
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Frontend dev server
|
# 首次初始化
|
||||||
cd web && npm install && npm run dev
|
.\gitm.exe --init
|
||||||
|
|
||||||
# Backend
|
# 启动服务
|
||||||
go run main.go
|
.\gitm.exe
|
||||||
|
|
||||||
# Run tests
|
# 指定监听地址
|
||||||
go test ./internal/gitea/... -v
|
.\gitm.exe --addr :8080
|
||||||
CGO_ENABLED=1 go test ./internal/database/... -v
|
|
||||||
|
# 指定数据目录
|
||||||
|
.\gitm.exe --data /path/to/data
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 开发模式
|
||||||
|
|
||||||
|
前后端分别启动,支持热更新:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 终端 1:启动后端(默认 :9000)
|
||||||
|
$env:CGO_ENABLED="1"; go run main.go
|
||||||
|
|
||||||
|
# 终端 2:启动前端开发服务器(:5173,自动代理 API 到 :9000)
|
||||||
|
cd web
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
开发时访问 `http://localhost:5173`
|
||||||
|
|
||||||
|
## 命令行参数
|
||||||
|
|
||||||
|
| 参数 | 说明 | 默认值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `--init` | 初始化数据库并设置管理员密码 | - |
|
||||||
|
| `--addr` | 监听地址 | `:9000` |
|
||||||
|
| `--data` | 数据存储目录 | `./data` |
|
||||||
|
|
||||||
|
## 系统设置
|
||||||
|
|
||||||
|
通过 Web 界面可配置:
|
||||||
|
|
||||||
|
- **管理员密码** - 修改登录密码
|
||||||
|
- **监听地址** - 修改服务端口(需重启)
|
||||||
|
- **仓库目录** - 仓库镜像存储路径
|
||||||
|
- **最大并发数** - 同步时的并发数量(1-10)
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
├── main.go # 程序入口
|
||||||
|
├── internal/
|
||||||
|
│ ├── config/ # 配置管理
|
||||||
|
│ ├── database/ # SQLite 数据库操作
|
||||||
|
│ ├── gitea/ # Gitea API 客户端
|
||||||
|
│ ├── handler/ # HTTP 请求处理
|
||||||
|
│ ├── middleware/ # JWT 认证中间件
|
||||||
|
│ ├── models/ # 数据模型
|
||||||
|
│ └── sync/ # 同步引擎与调度器
|
||||||
|
├── web/ # Vue 3 前端
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── api/ # API 请求封装
|
||||||
|
│ │ ├── views/ # 页面组件
|
||||||
|
│ │ ├── stores/ # Pinia 状态管理
|
||||||
|
│ │ └── router/ # 路由配置
|
||||||
|
│ └── vite.config.ts
|
||||||
|
└── data/ # 运行时数据(自动创建)
|
||||||
|
├── gitm.db # SQLite 数据库
|
||||||
|
└── repos/ # 镜像仓库存储
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| POST | `/api/login` | 登录 |
|
||||||
|
| POST | `/api/init` | 初始化密码 |
|
||||||
|
| GET | `/api/settings` | 获取系统设置 |
|
||||||
|
| PUT | `/api/settings` | 更新系统设置 |
|
||||||
|
| GET | `/api/servers` | 服务器列表 |
|
||||||
|
| POST | `/api/servers` | 添加服务器 |
|
||||||
|
| PUT | `/api/servers/:id` | 更新服务器 |
|
||||||
|
| DELETE | `/api/servers/:id` | 删除服务器 |
|
||||||
|
| POST | `/api/servers/test` | 测试连接 |
|
||||||
|
| GET | `/api/servers/:id/repos` | 仓库列表 |
|
||||||
|
| POST | `/api/servers/:id/discover` | 发现仓库 |
|
||||||
|
| POST | `/api/servers/:id/sync` | 同步服务器 |
|
||||||
|
| GET | `/api/servers/:id/sync/status` | 同步进度 |
|
||||||
|
| POST | `/api/sync/all` | 同步全部服务器 |
|
||||||
|
| GET | `/api/sync/logs` | 同步日志 |
|
||||||
|
| GET | `/api/sync/stats` | 同步统计 |
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
**后端:** Go / Gin / SQLite / JWT
|
||||||
|
|
||||||
|
**前端:** Vue 3 / TypeScript / Element Plus / Vite / Pinia
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ package database
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"gitm/internal/models"
|
"gitm/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDatabase(t *testing.T) {
|
func TestDatabase(t *testing.T) {
|
||||||
tmpDB := "/tmp/test_gitm.db"
|
tmpDB := filepath.Join(os.TempDir(), "test_gitm.db")
|
||||||
defer os.Remove(tmpDB)
|
defer os.Remove(tmpDB)
|
||||||
|
|
||||||
if err := Initialize(tmpDB); err != nil {
|
if err := Initialize(tmpDB); err != nil {
|
||||||
|
|||||||
@@ -30,11 +30,26 @@ func NewClient(serverURL, token string) (*Client, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) do(path string) (*http.Response, error) {
|
func (c *Client) do(path string) (*http.Response, error) {
|
||||||
apiURL := c.baseURL.JoinPath(path)
|
reqURL := c.baseURL.JoinPath(path)
|
||||||
q := apiURL.Query()
|
q := reqURL.Query()
|
||||||
q.Set("token", c.token)
|
q.Set("token", c.token)
|
||||||
apiURL.RawQuery = q.Encode()
|
reqURL.RawQuery = q.Encode()
|
||||||
resp, err := c.httpClient.Get(apiURL.String())
|
resp, err := c.httpClient.Get(reqURL.String())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("request failed: %w", err)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) doSearch(path string, params map[string]string) (*http.Response, error) {
|
||||||
|
reqURL := c.baseURL.JoinPath(path)
|
||||||
|
q := reqURL.Query()
|
||||||
|
q.Set("token", c.token)
|
||||||
|
for k, v := range params {
|
||||||
|
q.Set(k, v)
|
||||||
|
}
|
||||||
|
reqURL.RawQuery = q.Encode()
|
||||||
|
resp, err := c.httpClient.Get(reqURL.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("request failed: %w", err)
|
return nil, fmt.Errorf("request failed: %w", err)
|
||||||
}
|
}
|
||||||
@@ -59,8 +74,10 @@ func (c *Client) ValidateToken() (*GiteaUser, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) SearchRepos(page, limit int) ([]GiteaRepo, error) {
|
func (c *Client) SearchRepos(page, limit int) ([]GiteaRepo, error) {
|
||||||
path := fmt.Sprintf("/api/v1/repos/search?page=%d&limit=%d", page, limit)
|
resp, err := c.doSearch("/api/v1/repos/search", map[string]string{
|
||||||
resp, err := c.do(path)
|
"page": fmt.Sprintf("%d", page),
|
||||||
|
"limit": fmt.Sprintf("%d", limit),
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,10 +229,7 @@ func HandleGetSyncStatus(engine *sync.Engine) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
status := "idle"
|
progress := engine.GetProgress(id)
|
||||||
if engine.IsSyncing(id) {
|
c.JSON(http.StatusOK, progress)
|
||||||
status = "syncing"
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"server_id": id, "status": status})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package sync
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -13,16 +14,75 @@ import (
|
|||||||
"gitm/internal/models"
|
"gitm/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type RepoProgress struct {
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
Action string `json:"action"` // "cloning" or "fetching"
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerProgress struct {
|
||||||
|
ServerID int64 `json:"server_id"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Completed int `json:"completed"`
|
||||||
|
SuccessCount int `json:"success_count"`
|
||||||
|
FailedCount int `json:"failed_count"`
|
||||||
|
Current *RepoProgress `json:"current"`
|
||||||
|
Status string `json:"status"` // "syncing" or "idle"
|
||||||
|
}
|
||||||
|
|
||||||
type Engine struct {
|
type Engine struct {
|
||||||
maxConcurrent int
|
maxConcurrent int
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
activeTasks map[int64]string
|
activeTasks map[int64]string
|
||||||
|
progress map[int64]*ServerProgress
|
||||||
|
progressMu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewEngine(maxConcurrent int) *Engine {
|
func NewEngine(maxConcurrent int) *Engine {
|
||||||
return &Engine{
|
return &Engine{
|
||||||
maxConcurrent: maxConcurrent,
|
maxConcurrent: maxConcurrent,
|
||||||
activeTasks: make(map[int64]string),
|
activeTasks: make(map[int64]string),
|
||||||
|
progress: make(map[int64]*ServerProgress),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) GetProgress(serverID int64) *ServerProgress {
|
||||||
|
e.progressMu.RLock()
|
||||||
|
defer e.progressMu.RUnlock()
|
||||||
|
if p, ok := e.progress[serverID]; ok {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return &ServerProgress{ServerID: serverID, Status: "idle"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) setProgress(serverID int64, p *ServerProgress) {
|
||||||
|
e.progressMu.Lock()
|
||||||
|
defer e.progressMu.Unlock()
|
||||||
|
e.progress[serverID] = p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) updateProgressCounts(serverID int64, successDelta, failedDelta int) {
|
||||||
|
e.progressMu.Lock()
|
||||||
|
defer e.progressMu.Unlock()
|
||||||
|
if p, ok := e.progress[serverID]; ok {
|
||||||
|
p.SuccessCount += successDelta
|
||||||
|
p.FailedCount += failedDelta
|
||||||
|
p.Completed = p.SuccessCount + p.FailedCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) setCurrentRepo(serverID int64, fullName, action string) {
|
||||||
|
e.progressMu.Lock()
|
||||||
|
defer e.progressMu.Unlock()
|
||||||
|
if p, ok := e.progress[serverID]; ok {
|
||||||
|
p.Current = &RepoProgress{FullName: fullName, Action: action}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) clearCurrentRepo(serverID int64) {
|
||||||
|
e.progressMu.Lock()
|
||||||
|
defer e.progressMu.Unlock()
|
||||||
|
if p, ok := e.progress[serverID]; ok {
|
||||||
|
p.Current = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,33 +107,52 @@ func (e *Engine) SyncServer(serverID int64) error {
|
|||||||
return fmt.Errorf("failed to get server: %w", err)
|
return fmt.Errorf("failed to get server: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize progress
|
||||||
|
e.setProgress(serverID, &ServerProgress{
|
||||||
|
ServerID: serverID,
|
||||||
|
Total: 0,
|
||||||
|
Status: "syncing",
|
||||||
|
})
|
||||||
|
|
||||||
syncLog := &models.SyncLog{
|
syncLog := &models.SyncLog{
|
||||||
ServerID: serverID,
|
ServerID: serverID,
|
||||||
Status: "in_progress",
|
Status: "in_progress",
|
||||||
Message: "Starting sync",
|
Message: "开始同步",
|
||||||
StartedAt: time.Now(),
|
StartedAt: time.Now(),
|
||||||
}
|
}
|
||||||
if err := database.CreateSyncLog(syncLog); err != nil {
|
if err := database.CreateSyncLog(syncLog); err != nil {
|
||||||
return fmt.Errorf("failed to create sync log: %w", err)
|
return fmt.Errorf("failed to create sync log: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
e.setCurrentRepo(serverID, "", "验证认证中")
|
||||||
client, err := gitea.NewClient(server.URL, server.Token)
|
client, err := gitea.NewClient(server.URL, server.Token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.finishLog(syncLog, "failed", fmt.Sprintf("Failed to create client: %v", err))
|
e.finishLog(syncLog, "failed", fmt.Sprintf("创建客户端失败: %v", err))
|
||||||
|
e.setProgress(serverID, &ServerProgress{ServerID: serverID, Status: "idle"})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := client.ValidateToken(); err != nil {
|
if _, err := client.ValidateToken(); err != nil {
|
||||||
e.finishLog(syncLog, "failed", fmt.Sprintf("Authentication failed: %v", err))
|
e.finishLog(syncLog, "failed", fmt.Sprintf("认证失败: %v", err))
|
||||||
|
e.setProgress(serverID, &ServerProgress{ServerID: serverID, Status: "idle"})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
e.setCurrentRepo(serverID, "", "获取仓库列表中")
|
||||||
giteaRepos, err := client.GetAllRepos()
|
giteaRepos, err := client.GetAllRepos()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.finishLog(syncLog, "failed", fmt.Sprintf("Failed to fetch repos: %v", err))
|
e.finishLog(syncLog, "failed", fmt.Sprintf("获取仓库列表失败: %v", err))
|
||||||
|
e.setProgress(serverID, &ServerProgress{ServerID: serverID, Status: "idle"})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update total count
|
||||||
|
e.progressMu.Lock()
|
||||||
|
if p, ok := e.progress[serverID]; ok {
|
||||||
|
p.Total = len(giteaRepos)
|
||||||
|
}
|
||||||
|
e.progressMu.Unlock()
|
||||||
|
|
||||||
reposDir, _ := database.GetSetting("repos_dir")
|
reposDir, _ := database.GetSetting("repos_dir")
|
||||||
if reposDir == "" {
|
if reposDir == "" {
|
||||||
reposDir = "./data/repos"
|
reposDir = "./data/repos"
|
||||||
@@ -81,11 +160,8 @@ func (e *Engine) SyncServer(serverID int64) error {
|
|||||||
serverDir := filepath.Join(reposDir, fmt.Sprintf("server_%d_%s", serverID, server.Name))
|
serverDir := filepath.Join(reposDir, fmt.Sprintf("server_%d_%s", serverID, server.Name))
|
||||||
os.MkdirAll(serverDir, 0755)
|
os.MkdirAll(serverDir, 0755)
|
||||||
|
|
||||||
successCount := 0
|
|
||||||
failedCount := 0
|
|
||||||
sem := make(chan struct{}, e.maxConcurrent)
|
sem := make(chan struct{}, e.maxConcurrent)
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
var resultsMu sync.Mutex
|
|
||||||
|
|
||||||
for _, gr := range giteaRepos {
|
for _, gr := range giteaRepos {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
@@ -99,7 +175,8 @@ func (e *Engine) SyncServer(serverID int64) error {
|
|||||||
|
|
||||||
var err error
|
var err error
|
||||||
if existingRepo == nil {
|
if existingRepo == nil {
|
||||||
err = e.cloneMirror(giteaRepo.CloneURL, localPath)
|
e.setCurrentRepo(serverID, giteaRepo.FullName, "克隆")
|
||||||
|
err = e.cloneMirror(giteaRepo.CloneURL, server.Token, localPath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
repo := &models.Repo{
|
repo := &models.Repo{
|
||||||
ServerID: serverID,
|
ServerID: serverID,
|
||||||
@@ -112,15 +189,25 @@ func (e *Engine) SyncServer(serverID int64) error {
|
|||||||
database.CreateRepo(repo)
|
database.CreateRepo(repo)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if _, statErr := os.Stat(localPath); statErr != nil {
|
||||||
|
e.setCurrentRepo(serverID, giteaRepo.FullName, "克隆")
|
||||||
|
err = e.cloneMirror(giteaRepo.CloneURL, server.Token, localPath)
|
||||||
|
if err == nil {
|
||||||
|
existingRepo.LocalPath = localPath
|
||||||
|
database.UpdateRepoSyncStatus(existingRepo.ID, "success")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
e.setCurrentRepo(serverID, giteaRepo.FullName, "同步")
|
||||||
err = e.fetchMirror(localPath)
|
err = e.fetchMirror(localPath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
database.UpdateRepoSyncStatus(existingRepo.ID, "success")
|
database.UpdateRepoSyncStatus(existingRepo.ID, "success")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
resultsMu.Lock()
|
e.clearCurrentRepo(serverID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
failedCount++
|
e.updateProgressCounts(serverID, 0, 1)
|
||||||
failLog := &models.SyncLog{
|
failLog := &models.SyncLog{
|
||||||
ServerID: serverID,
|
ServerID: serverID,
|
||||||
RepoID: getRepoID(serverID, giteaRepo.FullName),
|
RepoID: getRepoID(serverID, giteaRepo.FullName),
|
||||||
@@ -130,26 +217,48 @@ func (e *Engine) SyncServer(serverID int64) error {
|
|||||||
}
|
}
|
||||||
database.CreateSyncLog(failLog)
|
database.CreateSyncLog(failLog)
|
||||||
} else {
|
} else {
|
||||||
successCount++
|
e.updateProgressCounts(serverID, 1, 0)
|
||||||
}
|
}
|
||||||
resultsMu.Unlock()
|
|
||||||
}(gr)
|
}(gr)
|
||||||
}
|
}
|
||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
database.UpdateServerLastSync(serverID)
|
database.UpdateServerLastSync(serverID)
|
||||||
|
|
||||||
message := fmt.Sprintf("Sync completed: %d succeeded, %d failed", successCount, failedCount)
|
// Get final counts for message
|
||||||
|
p := e.GetProgress(serverID)
|
||||||
|
message := fmt.Sprintf("同步完成: %d 成功, %d 失败", p.SuccessCount, p.FailedCount)
|
||||||
e.finishLog(syncLog, "success", message)
|
e.finishLog(syncLog, "success", message)
|
||||||
|
|
||||||
|
// Mark as idle but keep final progress visible briefly
|
||||||
|
e.progressMu.Lock()
|
||||||
|
finalP := e.progress[serverID]
|
||||||
|
if finalP != nil {
|
||||||
|
finalP.Current = nil
|
||||||
|
finalP.Status = "completed"
|
||||||
|
}
|
||||||
|
e.progressMu.Unlock()
|
||||||
|
|
||||||
|
// Clear after 30 seconds
|
||||||
|
go func() {
|
||||||
|
time.Sleep(30 * time.Second)
|
||||||
|
e.progressMu.Lock()
|
||||||
|
if p, ok := e.progress[serverID]; ok && p.Status == "completed" {
|
||||||
|
p.Status = "idle"
|
||||||
|
}
|
||||||
|
e.progressMu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) cloneMirror(url, path string) error {
|
func (e *Engine) cloneMirror(cloneURL, token, path string) error {
|
||||||
if _, err := os.Stat(path); err == nil {
|
if _, err := os.Stat(path); err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
os.MkdirAll(filepath.Dir(path), 0755)
|
os.MkdirAll(filepath.Dir(path), 0755)
|
||||||
cmd := exec.Command("git", "clone", "--mirror", url, path)
|
authURL := injectToken(cloneURL, token)
|
||||||
|
cmd := exec.Command("git", "clone", "--mirror", authURL, path)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("clone failed: %w - %s", err, string(output))
|
return fmt.Errorf("clone failed: %w - %s", err, string(output))
|
||||||
@@ -157,6 +266,15 @@ func (e *Engine) cloneMirror(url, path string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func injectToken(rawURL, token string) string {
|
||||||
|
u, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
u.User = url.UserPassword(token, "x-oauth-basic")
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
func (e *Engine) fetchMirror(path string) error {
|
func (e *Engine) fetchMirror(path string) error {
|
||||||
cmd := exec.Command("git", "--git-dir", path, "fetch", "--all", "--prune")
|
cmd := exec.Command("git", "--git-dir", path, "fetch", "--all", "--prune")
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
|
|||||||
25
main.go
25
main.go
@@ -101,16 +101,25 @@ func runServer(cfg *config.Config, engine *sync.Engine) error {
|
|||||||
// Serve embedded frontend
|
// Serve embedded frontend
|
||||||
distFS, err := fs.Sub(webFS, "web/dist")
|
distFS, err := fs.Sub(webFS, "web/dist")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Serve assets
|
fileServer := http.FileServer(http.FS(distFS))
|
||||||
r.StaticFS("/assets", http.FS(distFS))
|
|
||||||
// SPA fallback - serve index.html for non-API routes
|
|
||||||
r.NoRoute(func(c *gin.Context) {
|
r.NoRoute(func(c *gin.Context) {
|
||||||
if !strings.HasPrefix(c.Request.URL.Path, "/api") {
|
if strings.HasPrefix(c.Request.URL.Path, "/api") {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Try to serve static file first
|
||||||
|
name := strings.TrimPrefix(c.Request.URL.Path, "/")
|
||||||
|
if name != "" {
|
||||||
|
f, err := distFS.Open(name)
|
||||||
|
if err == nil {
|
||||||
|
f.Close()
|
||||||
|
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// SPA fallback - serve index.html
|
||||||
data, _ := webFS.ReadFile("web/dist/index.html")
|
data, _ := webFS.ReadFile("web/dist/index.html")
|
||||||
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
|
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
|
||||||
} else {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,10 +133,10 @@ func runServer(cfg *config.Config, engine *sync.Engine) error {
|
|||||||
protected.GET("/settings", handler.HandleGetSettings)
|
protected.GET("/settings", handler.HandleGetSettings)
|
||||||
protected.PUT("/settings", handler.HandleUpdateSettings)
|
protected.PUT("/settings", handler.HandleUpdateSettings)
|
||||||
protected.GET("/servers", handler.HandleListServers)
|
protected.GET("/servers", handler.HandleListServers)
|
||||||
|
protected.POST("/servers/test", handler.HandleTestConnection)
|
||||||
protected.POST("/servers", handler.HandleCreateServer)
|
protected.POST("/servers", handler.HandleCreateServer)
|
||||||
protected.PUT("/servers/:id", handler.HandleUpdateServer)
|
protected.PUT("/servers/:id", handler.HandleUpdateServer)
|
||||||
protected.DELETE("/servers/:id", handler.HandleDeleteServer)
|
protected.DELETE("/servers/:id", handler.HandleDeleteServer)
|
||||||
protected.POST("/servers/:id/test", handler.HandleTestConnection)
|
|
||||||
protected.GET("/servers/:id/repos", handler.HandleListRepos)
|
protected.GET("/servers/:id/repos", handler.HandleListRepos)
|
||||||
protected.POST("/servers/:id/discover", handler.HandleDiscoverRepos)
|
protected.POST("/servers/:id/discover", handler.HandleDiscoverRepos)
|
||||||
protected.POST("/servers/:id/sync", handler.HandleSyncServer(engine))
|
protected.POST("/servers/:id/sync", handler.HandleSyncServer(engine))
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<div style="padding:20px">
|
<div style="padding:20px">
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<el-col :span="6"><el-card shadow="hover"><el-statistic title="Total Servers" :value="stats.server_count" /></el-card></el-col>
|
<el-col :span="6"><el-card shadow="hover"><el-statistic title="服务器总数" :value="stats.server_count" /></el-card></el-col>
|
||||||
<el-col :span="6"><el-card shadow="hover"><el-statistic title="Active Servers" :value="stats.active_servers" /></el-card></el-col>
|
<el-col :span="6"><el-card shadow="hover"><el-statistic title="活跃服务器" :value="stats.active_servers" /></el-card></el-col>
|
||||||
<el-col :span="6"><el-card shadow="hover"><el-statistic title="Total Repos" :value="stats.repo_count" /></el-card></el-col>
|
<el-col :span="6"><el-card shadow="hover"><el-statistic title="仓库总数" :value="stats.repo_count" /></el-card></el-col>
|
||||||
<el-col :span="6"><el-card shadow="hover"><el-statistic title="Total Size" :value="formatSize(stats.total_size)" /></el-card></el-col>
|
<el-col :span="6"><el-card shadow="hover"><el-statistic title="总大小" :value="formatSize(stats.total_size)" /></el-card></el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
<el-card style="margin-top:20px">
|
<el-card style="margin-top:20px">
|
||||||
<template #header><span>Quick Actions</span></template>
|
<template #header><span>快捷操作</span></template>
|
||||||
<el-space>
|
<el-space>
|
||||||
<el-button type="primary" :loading="syncing" @click="syncAll">Sync All</el-button>
|
<el-button type="primary" :loading="syncing" @click="syncAll">同步全部</el-button>
|
||||||
<el-button @click="$router.push('/servers')">Add Server</el-button>
|
<el-button @click="$router.push('/servers')">添加服务器</el-button>
|
||||||
</el-space>
|
</el-space>
|
||||||
</el-card>
|
</el-card>
|
||||||
<el-card style="margin-top:20px">
|
<el-card style="margin-top:20px">
|
||||||
<template #header><div style="display:flex;justify-content:space-between"><span>Recent Activity</span><el-button text @click="$router.push('/logs')">View All</el-button></div></template>
|
<template #header><div style="display:flex;justify-content:space-between"><span>最近活动</span><el-button text @click="$router.push('/logs')">查看全部</el-button></div></template>
|
||||||
<el-table :data="logs" stripe>
|
<el-table :data="logs" stripe>
|
||||||
<el-table-column prop="started_at" label="Time" width="180"><template #default="{row}">{{ formatDate(row.started_at) }}</template></el-table-column>
|
<el-table-column prop="started_at" label="时间" width="180"><template #default="{row}">{{ formatDate(row.started_at) }}</template></el-table-column>
|
||||||
<el-table-column prop="server_id" label="Server" width="80" />
|
<el-table-column prop="server_id" label="服务器" width="80" />
|
||||||
<el-table-column prop="status" label="Status" width="100"><template #default="{row}"><el-tag :type="row.status==='success'?'success':row.status==='failed'?'danger':'info'">{{row.status}}</el-tag></template></el-table-column>
|
<el-table-column prop="status" label="状态" width="100"><template #default="{row}"><el-tag :type="row.status==='success'?'success':row.status==='failed'?'danger':'info'">{{statusText(row.status)}}</el-tag></template></el-table-column>
|
||||||
<el-table-column prop="message" label="Message" />
|
<el-table-column prop="message" label="消息" />
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,13 +41,14 @@ async function loadData() {
|
|||||||
|
|
||||||
async function syncAll() {
|
async function syncAll() {
|
||||||
syncing.value = true
|
syncing.value = true
|
||||||
try { await syncApi.syncAll(); ElMessage.success('Sync started'); setTimeout(loadData, 1000) }
|
try { await syncApi.syncAll(); ElMessage.success('同步已开始'); setTimeout(loadData, 1000) }
|
||||||
catch { ElMessage.error('Failed') }
|
catch { ElMessage.error('操作失败') }
|
||||||
finally { syncing.value = false }
|
finally { syncing.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSize(b:number) { if(!b)return '0 B'; const k=1024,s=['B','KB','MB','GB']; const i=Math.floor(Math.log(b)/Math.log(k)); return (b/Math.pow(k,i)).toFixed(1)+' '+s[i] }
|
function formatSize(b:number) { if(!b)return '0 B'; const k=1024,s=['B','KB','MB','GB']; const i=Math.floor(Math.log(b)/Math.log(k)); return (b/Math.pow(k,i)).toFixed(1)+' '+s[i] }
|
||||||
function formatDate(d:string) { return new Date(d).toLocaleString() }
|
function formatDate(d:string) { return new Date(d).toLocaleString() }
|
||||||
|
function statusText(s:string) { const m:Record<string,string>={success:'成功',failed:'失败',in_progress:'进行中',pending:'等待'}; return m[s]||s }
|
||||||
|
|
||||||
onMounted(loadData)
|
onMounted(loadData)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,18 +5,18 @@
|
|||||||
<h2 style="margin:0;color:#fff">GitM</h2>
|
<h2 style="margin:0;color:#fff">GitM</h2>
|
||||||
</div>
|
</div>
|
||||||
<el-menu :default-active="$route.path" router background-color="#001529" text-color="#fff" active-text-color="#1890ff">
|
<el-menu :default-active="$route.path" router background-color="#001529" text-color="#fff" active-text-color="#1890ff">
|
||||||
<el-menu-item index="/"><el-icon><DataBoard /></el-icon><span>Dashboard</span></el-menu-item>
|
<el-menu-item index="/"><el-icon><DataBoard /></el-icon><span>仪表盘</span></el-menu-item>
|
||||||
<el-menu-item index="/servers"><el-icon><Monitor /></el-icon><span>Servers</span></el-menu-item>
|
<el-menu-item index="/servers"><el-icon><Monitor /></el-icon><span>服务器</span></el-menu-item>
|
||||||
<el-menu-item index="/repos"><el-icon><DocumentCopy /></el-icon><span>Repositories</span></el-menu-item>
|
<el-menu-item index="/repos"><el-icon><DocumentCopy /></el-icon><span>仓库列表</span></el-menu-item>
|
||||||
<el-menu-item index="/logs"><el-icon><Document /></el-icon><span>Sync Logs</span></el-menu-item>
|
<el-menu-item index="/logs"><el-icon><Document /></el-icon><span>同步日志</span></el-menu-item>
|
||||||
<el-menu-item index="/settings"><el-icon><Setting /></el-icon><span>Settings</span></el-menu-item>
|
<el-menu-item index="/settings"><el-icon><Setting /></el-icon><span>系统设置</span></el-menu-item>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
</el-aside>
|
</el-aside>
|
||||||
<el-container>
|
<el-container>
|
||||||
<el-header style="background:#fff;border-bottom:1px solid #f0f0f0;display:flex;align-items:center">
|
<el-header style="background:#fff;border-bottom:1px solid #f0f0f0;display:flex;align-items:center">
|
||||||
<div style="width:100%;display:flex;justify-content:space-between;align-items:center">
|
<div style="width:100%;display:flex;justify-content:space-between;align-items:center">
|
||||||
<span style="font-size:18px;font-weight:500">{{ pageTitle }}</span>
|
<span style="font-size:18px;font-weight:500">{{ pageTitle }}</span>
|
||||||
<el-button text @click="handleLogout"><el-icon><SwitchButton /></el-icon> Logout</el-button>
|
<el-button text @click="handleLogout"><el-icon><SwitchButton /></el-icon> 退出登录</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-header>
|
</el-header>
|
||||||
<el-main style="background:#f5f5f5"><router-view /></el-main>
|
<el-main style="background:#f5f5f5"><router-view /></el-main>
|
||||||
@@ -34,7 +34,7 @@ const router = useRouter()
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const pageTitle = computed(() => {
|
const pageTitle = computed(() => {
|
||||||
const titles: Record<string,string> = { '/':'Dashboard','/servers':'Gitea Servers','/repos':'Repositories','/logs':'Sync Logs','/settings':'Settings' }
|
const titles: Record<string,string> = { '/':'仪表盘','/servers':'Gitea 服务器','/repos':'仓库列表','/logs':'同步日志','/settings':'系统设置' }
|
||||||
return titles[route.path] || 'GitM'
|
return titles[route.path] || 'GitM'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -4,15 +4,15 @@
|
|||||||
<template #header>
|
<template #header>
|
||||||
<div style="text-align:center">
|
<div style="text-align:center">
|
||||||
<h1 style="margin:0;color:#409eff">GitM</h1>
|
<h1 style="margin:0;color:#409eff">GitM</h1>
|
||||||
<p style="margin:5px 0 0;color:#909399;font-size:14px">Gitea Repository Sync Tool</p>
|
<p style="margin:5px 0 0;color:#909399;font-size:14px">Gitea 仓库同步工具</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-form @submit.prevent="handleLogin">
|
<el-form @submit.prevent="handleLogin">
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-input v-model="password" type="password" placeholder="Enter password" show-password size="large" @keyup.enter="handleLogin" />
|
<el-input v-model="password" type="password" placeholder="请输入密码" show-password size="large" @keyup.enter="handleLogin" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="handleLogin">Login</el-button>
|
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="handleLogin">登录</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<el-alert v-if="error" type="error" :title="error" :closable="false" style="margin-top:16px" />
|
<el-alert v-if="error" type="error" :title="error" :closable="false" style="margin-top:16px" />
|
||||||
@@ -33,12 +33,12 @@ const error = ref('')
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
async function handleLogin() {
|
async function handleLogin() {
|
||||||
if (!password.value) { error.value = 'Please enter password'; return }
|
if (!password.value) { error.value = '请输入密码'; return }
|
||||||
error.value = ''
|
error.value = ''
|
||||||
loading.value = true
|
loading.value = true
|
||||||
const ok = await authStore.login(password.value)
|
const ok = await authStore.login(password.value)
|
||||||
loading.value = false
|
loading.value = false
|
||||||
if (ok) { ElMessage.success('Login successful'); router.push('/') }
|
if (ok) { ElMessage.success('登录成功'); router.push('/') }
|
||||||
else { error.value = 'Invalid password' }
|
else { error.value = '密码错误' }
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div style="padding:20px">
|
<div style="padding:20px">
|
||||||
<el-card>
|
<el-card>
|
||||||
<template #header><div style="display:flex;justify-content:space-between;align-items:center"><span>Sync Logs</span>
|
<template #header><div style="display:flex;justify-content:space-between;align-items:center"><span>同步日志</span>
|
||||||
<el-select v-model="filterServer" clearable style="width:200px"><el-option v-for="s in servers" :key="s.id" :label="s.name" :value="s.id" /></el-select>
|
<el-select v-model="filterServer" clearable placeholder="筛选服务器" style="width:200px"><el-option v-for="s in servers" :key="s.id" :label="s.name" :value="s.id" /></el-select>
|
||||||
</div></template>
|
</div></template>
|
||||||
<el-table :data="logs" stripe v-loading="loading">
|
<el-table :data="logs" stripe v-loading="loading">
|
||||||
<el-table-column prop="started_at" label="Time" width="180"><template #default="{row}">{{new Date(row.started_at).toLocaleString()}}</template></el-table-column>
|
<el-table-column prop="started_at" label="时间" width="180"><template #default="{row}">{{new Date(row.started_at).toLocaleString()}}</template></el-table-column>
|
||||||
<el-table-column prop="server_id" label="Server" width="80" />
|
<el-table-column prop="server_id" label="服务器" width="80" />
|
||||||
<el-table-column prop="status" label="Status" width="100"><template #default="{row}"><el-tag :type="row.status==='success'?'success':row.status==='failed'?'danger':'info'">{{row.status}}</el-tag></template></el-table-column>
|
<el-table-column prop="status" label="状态" width="100"><template #default="{row}"><el-tag :type="row.status==='success'?'success':row.status==='failed'?'danger':'info'">{{statusText(row.status)}}</el-tag></template></el-table-column>
|
||||||
<el-table-column prop="message" label="Message" />
|
<el-table-column prop="message" label="消息" />
|
||||||
</el-table>
|
</el-table>
|
||||||
<el-pagination style="margin-top:20px;justify-content:center" layout="prev,pager,next" :page-size="50" @current-change="loadLogs" />
|
<el-pagination style="margin-top:20px;justify-content:center" layout="prev,pager,next" :page-size="50" @current-change="loadLogs" />
|
||||||
</el-card>
|
</el-card>
|
||||||
@@ -24,6 +24,8 @@ const logs=ref<any[]>([])
|
|||||||
const loading=ref(false)
|
const loading=ref(false)
|
||||||
const filterServer=ref<number|null>(null)
|
const filterServer=ref<number|null>(null)
|
||||||
|
|
||||||
|
function statusText(s:string){const m:Record<string,string>={success:'成功',failed:'失败',in_progress:'进行中'};return m[s]||s}
|
||||||
|
|
||||||
async function load(){try{servers.value=(await serverApi.list()).data}catch{}}
|
async function load(){try{servers.value=(await serverApi.list()).data}catch{}}
|
||||||
async function loadLogs(page=1){
|
async function loadLogs(page=1){
|
||||||
loading.value=true
|
loading.value=true
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div style="padding:20px">
|
<div style="padding:20px">
|
||||||
<el-card>
|
<el-card>
|
||||||
<template #header><div style="display:flex;justify-content:space-between;align-items:center"><span>Repositories</span>
|
<template #header><div style="display:flex;justify-content:space-between;align-items:center"><span>仓库列表</span>
|
||||||
<el-select v-model="selectedServer" style="width:200px"><el-option label="All Servers" :value="0" /><el-option v-for="s in servers" :key="s.id" :label="s.name" :value="s.id" /></el-select>
|
<el-select v-model="selectedServer" style="width:200px"><el-option label="全部服务器" :value="0" /><el-option v-for="s in servers" :key="s.id" :label="s.name" :value="s.id" /></el-select>
|
||||||
</div></template>
|
</div></template>
|
||||||
<el-table :data="repos" stripe v-loading="loading">
|
<el-table :data="repos" stripe v-loading="loading">
|
||||||
<el-table-column prop="full_name" label="Repository" />
|
<el-table-column prop="full_name" label="仓库名称" />
|
||||||
<el-table-column prop="server_id" label="Server" width="80" />
|
<el-table-column prop="server_id" label="服务器" width="80" />
|
||||||
<el-table-column prop="size" label="Size" width="120"><template #default="{row}">{{formatSize(row.size)}}</template></el-table-column>
|
<el-table-column prop="size" label="大小" width="120"><template #default="{row}">{{formatSize(row.size)}}</template></el-table-column>
|
||||||
<el-table-column prop="sync_status" label="Status" width="100"><template #default="{row}"><el-tag :type="row.sync_status==='success'?'success':row.sync_status==='failed'?'danger':row.sync_status==='syncing'?'warning':'info'">{{row.sync_status}}</el-tag></template></el-table-column>
|
<el-table-column prop="sync_status" label="状态" width="100"><template #default="{row}"><el-tag :type="row.sync_status==='success'?'success':row.sync_status==='failed'?'danger':row.sync_status==='syncing'?'warning':'info'">{{statusText(row.sync_status)}}</el-tag></template></el-table-column>
|
||||||
<el-table-column prop="last_sync_at" label="Last Sync" width="180"><template #default="{row}">{{row.last_sync_at?new Date(row.last_sync_at).toLocaleString():'Never'}}</template></el-table-column>
|
<el-table-column prop="last_sync_at" label="上次同步" width="180"><template #default="{row}">{{row.last_sync_at?new Date(row.last_sync_at).toLocaleString():'从未'}}</template></el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,6 +39,7 @@ async function loadRepos() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatSize(b:number){if(!b)return'0 B';const k=1024,s=['B','KB','MB','GB'];const i=Math.floor(Math.log(b)/Math.log(k));return(b/Math.pow(k,i)).toFixed(1)+' '+s[i]}
|
function formatSize(b:number){if(!b)return'0 B';const k=1024,s=['B','KB','MB','GB'];const i=Math.floor(Math.log(b)/Math.log(k));return(b/Math.pow(k,i)).toFixed(1)+' '+s[i]}
|
||||||
|
function statusText(s:string){const m:Record<string,string>={success:'成功',failed:'失败',in_progress:'同步中',pending:'等待'};return m[s]||s}
|
||||||
|
|
||||||
watch(selectedServer,loadRepos)
|
watch(selectedServer,loadRepos)
|
||||||
onMounted(async()=>{await load();loadRepos()})
|
onMounted(async()=>{await load();loadRepos()})
|
||||||
|
|||||||
@@ -1,43 +1,68 @@
|
|||||||
<template>
|
<template>
|
||||||
<div style="padding:20px">
|
<div style="padding:20px">
|
||||||
<el-card>
|
<el-card>
|
||||||
<template #header><div style="display:flex;justify-content:space-between;align-items:center"><span>Gitea Servers</span><el-button type="primary" @click="showDialog=true;editMode=false;form={name:'',url:'',token:'',sync_interval:0}">Add Server</el-button></div></template>
|
<template #header><div style="display:flex;justify-content:space-between;align-items:center"><span>Gitea 服务器</span><el-button type="primary" @click="showDialog=true;editMode=false;form={name:'',url:'',token:'',sync_interval:0}">添加服务器</el-button></div></template>
|
||||||
<el-table :data="servers" stripe v-loading="loading">
|
<el-table :data="servers" stripe v-loading="loading">
|
||||||
<el-table-column prop="id" label="ID" width="60" />
|
<el-table-column prop="id" label="ID" width="60" />
|
||||||
<el-table-column prop="name" label="Name" />
|
<el-table-column prop="name" label="名称" />
|
||||||
<el-table-column prop="url" label="URL" />
|
<el-table-column prop="url" label="地址" />
|
||||||
<el-table-column prop="sync_interval" label="Interval" width="100"><template #default="{row}">{{row.sync_interval>0?row.sync_interval+' min':'Manual'}}</template></el-table-column>
|
<el-table-column prop="sync_interval" label="同步间隔" width="100"><template #default="{row}">{{row.sync_interval>0?row.sync_interval+' 分钟':'手动'}}</template></el-table-column>
|
||||||
<el-table-column prop="status" label="Status" width="100"><template #default="{row}"><el-tag :type="row.status==='active'?'success':'info'">{{row.status}}</el-tag></template></el-table-column>
|
<el-table-column prop="status" label="状态" width="100"><template #default="{row}"><el-tag :type="row.status==='active'?'success':'info'">{{row.status==='active'?'活跃':'禁用'}}</el-tag></template></el-table-column>
|
||||||
<el-table-column prop="last_sync_at" label="Last Sync" width="180"><template #default="{row}">{{row.last_sync_at?new Date(row.last_sync_at).toLocaleString():'Never'}}</template></el-table-column>
|
<el-table-column prop="last_sync_at" label="上次同步" width="180"><template #default="{row}">{{row.last_sync_at?new Date(row.last_sync_at).toLocaleString():'从未'}}</template></el-table-column>
|
||||||
<el-table-column label="Actions" width="280" fixed="right">
|
<el-table-column label="操作" width="280" fixed="right">
|
||||||
<template #default="{row}">
|
<template #default="{row}">
|
||||||
<el-button size="small" @click="discover(row)">Discover</el-button>
|
<el-button size="small" @click="discover(row)">发现</el-button>
|
||||||
<el-button size="small" type="primary" @click="syncOne(row)">Sync</el-button>
|
<el-button size="small" type="primary" @click="syncOne(row)">同步</el-button>
|
||||||
<el-button size="small" @click="edit(row)">Edit</el-button>
|
<el-button size="small" @click="edit(row)">编辑</el-button>
|
||||||
<el-button size="small" type="danger" @click="remove(row)">Delete</el-button>
|
<el-button size="small" type="danger" @click="remove(row)">删除</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-dialog v-model="showDialog" :title="editMode?'Edit Server':'Add Server'" width="500px">
|
<!-- 同步进度面板 -->
|
||||||
|
<el-card v-if="progressList.length > 0" style="margin-top:20px">
|
||||||
|
<template #header><span>同步进度</span></template>
|
||||||
|
<div v-for="p in progressList" :key="p.server_id" style="margin-bottom:16px">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||||
|
<span style="font-weight:500">{{ getServerName(p.server_id) }}</span>
|
||||||
|
<span style="color:#909399">{{ p.completed }} / {{ p.total }}</span>
|
||||||
|
</div>
|
||||||
|
<el-progress
|
||||||
|
:percentage="p.total > 0 ? Math.round(p.completed / p.total * 100) : 0"
|
||||||
|
:status="p.status === 'completed' ? 'success' : undefined"
|
||||||
|
:stroke-width="20"
|
||||||
|
:text-inside="true"
|
||||||
|
style="margin-bottom:6px"
|
||||||
|
/>
|
||||||
|
<div v-if="p.current" style="display:flex;align-items:center;gap:8px;margin-top:4px">
|
||||||
|
<el-tag type="warning" size="small" effect="dark">{{ p.current.action }}中</el-tag>
|
||||||
|
<span style="font-size:13px;color:#606266">{{ p.current.full_name }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="p.status === 'completed'" style="margin-top:4px;font-size:13px;color:#67c23a">
|
||||||
|
同步完成: {{ p.success_count }} 成功, {{ p.failed_count }} 失败
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog v-model="showDialog" :title="editMode?'编辑服务器':'添加服务器'" width="500px">
|
||||||
<el-form :model="form" label-width="120px">
|
<el-form :model="form" label-width="120px">
|
||||||
<el-form-item label="Name"><el-input v-model="form.name" /></el-form-item>
|
<el-form-item label="名称"><el-input v-model="form.name" /></el-form-item>
|
||||||
<el-form-item label="URL"><el-input v-model="form.url" placeholder="https://git.example.com" /></el-form-item>
|
<el-form-item label="地址"><el-input v-model="form.url" placeholder="https://git.example.com" /></el-form-item>
|
||||||
<el-form-item label="Token"><el-input v-model="form.token" type="password" show-password /></el-form-item>
|
<el-form-item label="Token"><el-input v-model="form.token" type="password" show-password /></el-form-item>
|
||||||
<el-form-item label="Sync Interval"><el-input-number v-model="form.sync_interval" :min="0" :max="1440" /><span style="margin-left:10px;color:#909399">min (0=manual)</span></el-form-item>
|
<el-form-item label="同步间隔"><el-input-number v-model="form.sync_interval" :min="0" :max="1440" /><span style="margin-left:10px;color:#909399">分钟 (0=手动)</span></el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<el-space style="margin-top:16px">
|
<el-space style="margin-top:16px">
|
||||||
<el-button type="primary" @click="save">Save</el-button>
|
<el-button type="primary" @click="save">保存</el-button>
|
||||||
<el-button @click="testConn">Test Connection</el-button>
|
<el-button @click="testConn">测试连接</el-button>
|
||||||
<el-button @click="showDialog=false">Cancel</el-button>
|
<el-button @click="showDialog=false">取消</el-button>
|
||||||
</el-space>
|
</el-space>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { serverApi } from '@/api'
|
import { serverApi } from '@/api'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
@@ -47,9 +72,33 @@ const showDialog = ref(false)
|
|||||||
const editMode = ref(false)
|
const editMode = ref(false)
|
||||||
const editingId = ref(0)
|
const editingId = ref(0)
|
||||||
const form = ref({ name:'', url:'', token:'', sync_interval:0 })
|
const form = ref({ name:'', url:'', token:'', sync_interval:0 })
|
||||||
|
const progressList = ref<any[]>([])
|
||||||
|
let pollTimer: any = null
|
||||||
|
|
||||||
async function load() { loading.value=true; try { servers.value=(await serverApi.list()).data } catch{} finally{loading.value=false} }
|
async function load() { loading.value=true; try { servers.value=(await serverApi.list()).data } catch{} finally{loading.value=false} }
|
||||||
|
|
||||||
|
function getServerName(id: number) {
|
||||||
|
const s = servers.value.find((s: any) => s.id === id)
|
||||||
|
return s ? s.name : `服务器 #${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollProgress() {
|
||||||
|
if (!servers.value || servers.value.length === 0) return
|
||||||
|
const syncing = servers.value.filter(() => true)
|
||||||
|
if (syncing.length === 0) return
|
||||||
|
|
||||||
|
const results: any[] = []
|
||||||
|
for (const s of syncing) {
|
||||||
|
try {
|
||||||
|
const r = (await serverApi.syncStatus(s.id)).data
|
||||||
|
if (r.status !== 'idle') {
|
||||||
|
results.push(r)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
progressList.value = results
|
||||||
|
}
|
||||||
|
|
||||||
function edit(row:any) { editMode.value=true; editingId.value=row.id; form.value={name:row.name,url:row.url,token:'',sync_interval:row.sync_interval}; showDialog.value=true }
|
function edit(row:any) { editMode.value=true; editingId.value=row.id; form.value={name:row.name,url:row.url,token:'',sync_interval:row.sync_interval}; showDialog.value=true }
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
@@ -61,18 +110,37 @@ async function save() {
|
|||||||
} else {
|
} else {
|
||||||
await serverApi.create(form.value)
|
await serverApi.create(form.value)
|
||||||
}
|
}
|
||||||
ElMessage.success(editMode.value?'Updated':'Added'); showDialog.value=false; load()
|
ElMessage.success(editMode.value?'更新成功':'添加成功'); showDialog.value=false; load()
|
||||||
} catch { ElMessage.error('Failed') }
|
} catch { ElMessage.error('操作失败') }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testConn() {
|
async function testConn() {
|
||||||
try { const r=(await serverApi.test({url:form.value.url,token:form.value.token})).data; ElMessage.success('Connected! User: '+r.user) }
|
try { const r=(await serverApi.test({url:form.value.url,token:form.value.token})).data; ElMessage.success('连接成功!用户: '+r.user) }
|
||||||
catch { ElMessage.error('Connection failed') }
|
catch { ElMessage.error('连接失败') }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function discover(row:any) { try { const r=(await serverApi.discover(row.id)).data; ElMessage.success(r.message) } catch { ElMessage.error('Failed') } }
|
async function discover(row:any) { try { const r=(await serverApi.discover(row.id)).data; ElMessage.success(r.message) } catch(e:any) { ElMessage.error('发现失败: '+(e.response?.data?.error||e.message)) } }
|
||||||
async function syncOne(row:any) { try { await serverApi.sync(row.id); ElMessage.success('Sync started') } catch(e:any) { if(e.response?.status===409) ElMessage.warning('Already syncing'); else ElMessage.error('Failed') } }
|
|
||||||
function remove(row:any) { ElMessageBox.confirm('Delete this server?','Confirm',{type:'warning'}).then(async()=>{ await serverApi.delete(row.id); ElMessage.success('Deleted'); load() }).catch(()=>{}) }
|
|
||||||
|
|
||||||
onMounted(load)
|
async function syncOne(row:any) {
|
||||||
|
try {
|
||||||
|
await serverApi.sync(row.id)
|
||||||
|
ElMessage.success('同步已开始')
|
||||||
|
pollProgress()
|
||||||
|
} catch(e:any) {
|
||||||
|
if(e.response?.status===409) ElMessage.warning('正在同步中')
|
||||||
|
else ElMessage.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(row:any) { ElMessageBox.confirm('确定删除该服务器?','确认',{type:'warning'}).then(async()=>{ await serverApi.delete(row.id); ElMessage.success('已删除'); load() }).catch(()=>{}) }
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
load()
|
||||||
|
pollTimer = setInterval(pollProgress, 2000)
|
||||||
|
pollProgress()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (pollTimer) clearInterval(pollTimer)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div style="padding:20px">
|
<div style="padding:20px">
|
||||||
<el-card>
|
<el-card>
|
||||||
<template #header><span>Settings</span></template>
|
<template #header><span>系统设置</span></template>
|
||||||
<el-form :model="form" label-width="150px" style="max-width:600px">
|
<el-form :model="form" label-width="150px" style="max-width:600px">
|
||||||
<el-form-item label="New Password"><el-input v-model="form.password" type="password" show-password placeholder="Leave empty to keep" /></el-form-item>
|
<el-form-item label="新密码"><el-input v-model="form.password" type="password" show-password placeholder="留空则不修改" /></el-form-item>
|
||||||
<el-form-item label="Listen Address"><el-input v-model="form.listen_addr" /></el-form-item>
|
<el-form-item label="监听地址"><el-input v-model="form.listen_addr" /></el-form-item>
|
||||||
<el-form-item label="Repos Directory"><el-input v-model="form.repos_dir" /></el-form-item>
|
<el-form-item label="仓库目录"><el-input v-model="form.repos_dir" /></el-form-item>
|
||||||
<el-form-item label="Max Concurrent"><el-input-number v-model="form.max_concurrent" :min="1" :max="10" /></el-form-item>
|
<el-form-item label="最大并发数"><el-input-number v-model="form.max_concurrent" :min="1" :max="10" /></el-form-item>
|
||||||
<el-form-item><el-button type="primary" @click="save">Save</el-button></el-form-item>
|
<el-form-item><el-button type="primary" @click="save">保存</el-button></el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<el-alert type="info" title="Listen address change requires restart" :closable="false" style="margin-top:16px" />
|
<el-alert type="info" title="修改监听地址后需要重启程序才能生效" :closable="false" style="margin-top:16px" />
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -30,8 +30,8 @@ async function save(){
|
|||||||
try{
|
try{
|
||||||
const data:any={listen_addr:form.value.listen_addr,repos_dir:form.value.repos_dir,max_concurrent:form.value.max_concurrent}
|
const data:any={listen_addr:form.value.listen_addr,repos_dir:form.value.repos_dir,max_concurrent:form.value.max_concurrent}
|
||||||
if(form.value.password)data.admin_password=form.value.password
|
if(form.value.password)data.admin_password=form.value.password
|
||||||
await authApi.updateSettings(data);ElMessage.success('Saved');form.value.password=''
|
await authApi.updateSettings(data);ElMessage.success('保存成功');form.value.password=''
|
||||||
}catch{ElMessage.error('Failed')}
|
}catch{ElMessage.error('保存失败')}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
|
|||||||
Reference in New Issue
Block a user