- 添加Claude本地设置文件,允许Bash操作权限 - 将文档中的分隔线从"---"统一改为"***" - 修复markdown代码块嵌套格式问题 - 调整任务列表格式,移除多余空行 - 修正HTTP链接的markdown格式 - 更新.gitignore文件格式,修复转义字符问题
4587 lines
101 KiB
Markdown
4587 lines
101 KiB
Markdown
# GitM Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Build a cross-platform Gitea repository sync tool with a single-binary Go backend and embedded Vue 3 frontend
|
|
|
|
**Architecture:** Go (Gin) backend with SQLite storage, Vue 3 + Element Plus frontend embedded via Go embed, JWT authentication, Gitea API integration for repo discovery and git-based mirroring
|
|
|
|
**Tech Stack:** Go 1.26+, Gin, SQLite (mattn/go-sqlite3), Vue 3, Element Plus, Pinia, Vite, golang-jwt/jwt, bcrypt
|
|
|
|
***
|
|
|
|
## Phase 1: Project Initialization and Core Infrastructure
|
|
|
|
### Task 1: Initialize Go Module and Project Structure
|
|
|
|
**Files:**
|
|
|
|
- Create: `go.mod`
|
|
- Create: `go.sum` (auto-generated)
|
|
- Create: `Makefile`
|
|
- Create: `main.go`
|
|
- Create: `README.md`
|
|
- [ ] **Step 1: Initialize Go module**
|
|
|
|
```bash
|
|
cd /c/electron/gitm
|
|
go mod init gitm
|
|
```
|
|
|
|
- [ ] **Step 2: Create go.mod with dependencies**
|
|
|
|
Create `go.mod`:
|
|
|
|
```go
|
|
module gitm
|
|
|
|
go 1.26
|
|
|
|
require (
|
|
github.com/gin-gonic/gin v1.10.0
|
|
github.com/mattn/go-sqlite3 v1.14.22
|
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
|
golang.org/x/crypto v0.25.0
|
|
github.com/robfig/cron/v3 v3.0.1
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 3: Download dependencies**
|
|
|
|
```bash
|
|
go mod tidy
|
|
```
|
|
|
|
Expected: `go.sum` created, dependencies downloaded
|
|
|
|
- [ ] **Step 4: Create Makefile**
|
|
|
|
Create `Makefile`:
|
|
|
|
```makefile
|
|
.PHONY: all build frontend clean test run
|
|
|
|
# Build frontend first, then build the binary
|
|
all: frontend build
|
|
|
|
# Build Vue frontend
|
|
frontend:
|
|
cd web && npm install && npm run build
|
|
|
|
# Build Go binary
|
|
build:
|
|
go build -o bin/gitm .
|
|
|
|
# Clean build artifacts
|
|
clean:
|
|
rm -rf bin/
|
|
rm -rf web/dist/
|
|
rm -rf web/node_modules/
|
|
|
|
# Run tests
|
|
test:
|
|
go test -v ./...
|
|
|
|
# Run the application
|
|
run:
|
|
go run main.go
|
|
|
|
# Cross-compile for Linux
|
|
build-linux:
|
|
GOOS=linux GOARCH=amd64 go build -o bin/gitm-linux .
|
|
|
|
# Cross-compile for Windows
|
|
build-windows:
|
|
GOOS=windows GOARCH=amd64 go build -o bin/gitm.exe .
|
|
```
|
|
|
|
- [ ] **Step 5: Create main.go skeleton**
|
|
|
|
Create `main.go`:
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
)
|
|
|
|
var (
|
|
flagAddr = flag.String("addr", ":9000", "Listen address")
|
|
flagDataDir = flag.String("data", "./data", "Data directory")
|
|
flagInit = flag.Bool("init", false, "Initialize database and set password")
|
|
)
|
|
|
|
func main() {
|
|
flag.Parse()
|
|
|
|
fmt.Printf("GitM - Gitea Repository Sync Tool\n")
|
|
fmt.Printf("Listen: %s\n", *flagAddr)
|
|
fmt.Printf("Data: %s\n", *flagDataDir)
|
|
|
|
if *flagInit {
|
|
fmt.Println("Initialize mode: TODO")
|
|
return
|
|
}
|
|
|
|
log.Fatal(runServer())
|
|
}
|
|
|
|
func runServer() error {
|
|
// TODO: implement server
|
|
return fmt.Errorf("not implemented")
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Create README.md**
|
|
|
|
Create `README.md`:
|
|
|
|
````markdown
|
|
# GitM - Gitea Repository Sync Tool
|
|
|
|
Cross-platform tool to synchronize all repositories from multiple Gitea servers to local storage.
|
|
|
|
## Features
|
|
|
|
- Single binary deployment
|
|
- Web UI for management (Vue 3 + Element Plus)
|
|
- SQLite database
|
|
- JWT authentication
|
|
- Scheduled and manual sync
|
|
- Cross-platform (Windows, Linux)
|
|
|
|
## Build
|
|
|
|
```bash
|
|
make all
|
|
````
|
|
|
|
## Run
|
|
|
|
```bash
|
|
# First run - initialize
|
|
./bin/gitm --init
|
|
|
|
# Normal run
|
|
./bin/gitm
|
|
|
|
# Custom port
|
|
./bin/gitm --addr :9090
|
|
```
|
|
|
|
## Development
|
|
|
|
```bash
|
|
# Install frontend deps
|
|
cd web && npm install
|
|
|
|
# Run frontend dev server
|
|
cd web && npm run dev
|
|
|
|
# Run backend (requires frontend built first)
|
|
go run main.go
|
|
```
|
|
|
|
````
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add go.mod go.sum Makefile main.go README.md
|
|
git commit -m "feat: initialize project structure and dependencies"
|
|
````
|
|
|
|
***
|
|
|
|
### Task 2: Create Internal Package Structure
|
|
|
|
**Files:**
|
|
|
|
- Create: `internal/config/config.go`
|
|
- Create: `internal/models/models.go`
|
|
- [ ] **Step 1: Create config package**
|
|
|
|
Create `internal/config/config.go`:
|
|
|
|
```go
|
|
package config
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
)
|
|
|
|
type Config struct {
|
|
ListenAddr string
|
|
DataDir string
|
|
DBPath string
|
|
ReposDir string
|
|
once sync.Once
|
|
}
|
|
|
|
var (
|
|
instance *Config
|
|
initOnce sync.Once
|
|
)
|
|
|
|
func Get() *Config {
|
|
initOnce.Do(func() {
|
|
instance = &Config{
|
|
ListenAddr: ":9000",
|
|
DataDir: "./data",
|
|
}
|
|
})
|
|
return instance
|
|
}
|
|
|
|
func (c *Config) SetDataDir(dir string) {
|
|
c.DataDir = dir
|
|
c.DBPath = filepath.Join(dir, "gitm.db")
|
|
c.ReposDir = filepath.Join(dir, "repos")
|
|
}
|
|
|
|
func (c *Config) EnsureDirs() error {
|
|
if err := os.MkdirAll(c.DataDir, 0755); err != nil {
|
|
return err
|
|
}
|
|
return os.MkdirAll(c.ReposDir, 0755)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create models package**
|
|
|
|
Create `internal/models/models.go`:
|
|
|
|
```go
|
|
package models
|
|
|
|
import "time"
|
|
|
|
// GiteaServer represents a Gitea server configuration
|
|
type GiteaServer struct {
|
|
ID int64 `json:"id" db:"id"`
|
|
Name string `json:"name" db:"name"`
|
|
URL string `json:"url" db:"url"`
|
|
Token string `json:"-" db:"token"` // Never expose token in JSON
|
|
SyncInterval int `json:"sync_interval" db:"sync_interval"` // 0 = manual only
|
|
LastSyncAt *time.Time `json:"last_sync_at" db:"last_sync_at"`
|
|
Status string `json:"status" db:"status"` // active, disabled
|
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
|
}
|
|
|
|
// Repo represents a discovered repository
|
|
type Repo struct {
|
|
ID int64 `json:"id" db:"id"`
|
|
ServerID int64 `json:"server_id" db:"server_id"`
|
|
Name string `json:"name" db:"name"`
|
|
FullName string `json:"full_name" db:"full_name"`
|
|
CloneURL string `json:"clone_url" db:"clone_url"`
|
|
LocalPath string `json:"local_path" db:"local_path"`
|
|
Size int64 `json:"size" db:"size"`
|
|
LastSyncAt *time.Time `json:"last_sync_at" db:"last_sync_at"`
|
|
SyncStatus string `json:"sync_status" db:"sync_status"` // syncing, success, failed, pending
|
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
|
}
|
|
|
|
// SyncLog represents a sync operation log
|
|
type SyncLog struct {
|
|
ID int64 `json:"id" db:"id"`
|
|
ServerID int64 `json:"server_id" db:"server_id"`
|
|
RepoID *int64 `json:"repo_id" db:"repo_id"`
|
|
Status string `json:"status" db:"status"`
|
|
Message string `json:"message" db:"message"`
|
|
StartedAt time.Time `json:"started_at" db:"started_at"`
|
|
FinishedAt *time.Time `json:"finished_at" db:"finished_at"`
|
|
}
|
|
|
|
// Setting represents a key-value configuration
|
|
type Setting struct {
|
|
Key string `json:"key" db:"key"`
|
|
Value string `json:"value" db:"value"`
|
|
}
|
|
|
|
// SyncStatus represents the current sync state
|
|
type SyncStatus struct {
|
|
ServerID int64 `json:"server_id"`
|
|
Status string `json:"status"` // idle, syncing, failed
|
|
TaskID string `json:"task_id,omitempty"`
|
|
StartedAt string `json:"started_at,omitempty"`
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add internal/config/config.go internal/models/models.go
|
|
git commit -m "feat: add config and models packages"
|
|
```
|
|
|
|
***
|
|
|
|
### Task 3: Implement Database Layer
|
|
|
|
**Files:**
|
|
|
|
- Create: `internal/database/database.go`
|
|
- [ ] **Step 1: Create database package**
|
|
|
|
Create `internal/database/database.go`:
|
|
|
|
```go
|
|
package database
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"path/filepath"
|
|
|
|
_ "github.com/mattn/go-sqlite3"
|
|
"gitm/internal/config"
|
|
"gitm/internal/models"
|
|
)
|
|
|
|
var DB *sql.DB
|
|
|
|
// Initialize opens the database connection and creates tables
|
|
func Initialize(dbPath string) error {
|
|
var err error
|
|
DB, err = sql.Open("sqlite3", dbPath+"?_foreign_keys=on")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open database: %w", err)
|
|
}
|
|
|
|
if err = DB.Ping(); err != nil {
|
|
return fmt.Errorf("failed to ping database: %w", err)
|
|
}
|
|
|
|
if err = createTables(); err != nil {
|
|
return fmt.Errorf("failed to create tables: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func createTables() error {
|
|
queries := []string{
|
|
`CREATE TABLE IF NOT EXISTS gitea_servers (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
url TEXT NOT NULL,
|
|
token TEXT NOT NULL,
|
|
sync_interval INTEGER DEFAULT 0,
|
|
last_sync_at DATETIME,
|
|
status TEXT DEFAULT 'active',
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS repos (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
server_id INTEGER NOT NULL,
|
|
name TEXT,
|
|
full_name TEXT,
|
|
clone_url TEXT,
|
|
local_path TEXT,
|
|
size INTEGER DEFAULT 0,
|
|
last_sync_at DATETIME,
|
|
sync_status TEXT DEFAULT 'pending',
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (server_id) REFERENCES gitea_servers(id) ON DELETE CASCADE
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS sync_logs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
server_id INTEGER NOT NULL,
|
|
repo_id INTEGER,
|
|
status TEXT NOT NULL,
|
|
message TEXT,
|
|
started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
finished_at DATETIME,
|
|
FOREIGN KEY (server_id) REFERENCES gitea_servers(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (repo_id) REFERENCES repos(id) ON DELETE SET NULL
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT
|
|
)`,
|
|
}
|
|
|
|
for _, q := range queries {
|
|
if _, err := DB.Exec(q); err != nil {
|
|
return fmt.Errorf("failed to create table: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Close closes the database connection
|
|
func Close() error {
|
|
if DB != nil {
|
|
return DB.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Settings operations
|
|
func GetSetting(key string) (string, error) {
|
|
var value string
|
|
err := DB.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&value)
|
|
if err == sql.ErrNoRows {
|
|
return "", nil
|
|
}
|
|
return value, err
|
|
}
|
|
|
|
func SetSetting(key, value string) error {
|
|
_, err := DB.Exec(`
|
|
INSERT INTO settings (key, value) VALUES (?, ?)
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
`, key, value)
|
|
return err
|
|
}
|
|
|
|
// Server operations
|
|
func CreateServer(server *models.GiteaServer) error {
|
|
result, err := DB.Exec(`
|
|
INSERT INTO gitea_servers (name, url, token, sync_interval, status)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`, server.Name, server.URL, server.Token, server.SyncInterval, server.Status)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
id, err := result.LastInsertId()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
server.ID = id
|
|
return nil
|
|
}
|
|
|
|
func GetServers() ([]models.GiteaServer, error) {
|
|
rows, err := DB.Query("SELECT id, name, url, token, sync_interval, last_sync_at, status, created_at FROM gitea_servers ORDER BY id")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var servers []models.GiteaServer
|
|
for rows.Next() {
|
|
var s models.GiteaServer
|
|
err := rows.Scan(&s.ID, &s.Name, &s.URL, &s.Token, &s.SyncInterval, &s.LastSyncAt, &s.Status, &s.CreatedAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
servers = append(servers, s)
|
|
}
|
|
return servers, nil
|
|
}
|
|
|
|
func GetServer(id int64) (*models.GiteaServer, error) {
|
|
var s models.GiteaServer
|
|
err := DB.QueryRow(`
|
|
SELECT id, name, url, token, sync_interval, last_sync_at, status, created_at
|
|
FROM gitea_servers WHERE id = ?
|
|
`, id).Scan(&s.ID, &s.Name, &s.URL, &s.Token, &s.SyncInterval, &s.LastSyncAt, &s.Status, &s.CreatedAt)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return &s, err
|
|
}
|
|
|
|
func UpdateServer(server *models.GiteaServer) error {
|
|
_, err := DB.Exec(`
|
|
UPDATE gitea_servers
|
|
SET name = ?, url = ?, token = ?, sync_interval = ?, status = ?
|
|
WHERE id = ?
|
|
`, server.Name, server.URL, server.Token, server.SyncInterval, server.Status, server.ID)
|
|
return err
|
|
}
|
|
|
|
func DeleteServer(id int64) error {
|
|
_, err := DB.Exec("DELETE FROM gitea_servers WHERE id = ?", id)
|
|
return err
|
|
}
|
|
|
|
func UpdateServerLastSync(id int64, t time.Time) error {
|
|
_, err := DB.Exec("UPDATE gitea_servers SET last_sync_at = ? WHERE id = ?", t, id)
|
|
return err
|
|
}
|
|
|
|
// Repo operations
|
|
func CreateOrUpdateRepo(repo *models.Repo) error {
|
|
result, err := DB.Exec(`
|
|
INSERT INTO repos (server_id, name, full_name, clone_url, local_path, sync_status)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT DO NOTHING
|
|
`, repo.ServerID, repo.Name, repo.FullName, repo.CloneURL, repo.LocalPath, repo.SyncStatus)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
id, err := result.LastInsertId()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
repo.ID = id
|
|
return nil
|
|
}
|
|
|
|
func GetReposByServer(serverID int64) ([]models.Repo, error) {
|
|
rows, err := DB.Query(`
|
|
SELECT id, server_id, name, full_name, clone_url, local_path, size, last_sync_at, sync_status, created_at
|
|
FROM repos WHERE server_id = ? ORDER BY full_name
|
|
`, serverID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var repos []models.Repo
|
|
for rows.Next() {
|
|
var r models.Repo
|
|
err := rows.Scan(&r.ID, &r.ServerID, &r.Name, &r.FullName, &r.CloneURL, &r.LocalPath, &r.Size, &r.LastSyncAt, &r.SyncStatus, &r.CreatedAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
repos = append(repos, r)
|
|
}
|
|
return repos, nil
|
|
}
|
|
|
|
func UpdateRepoSyncStatus(id int64, status string) error {
|
|
_, err := DB.Exec("UPDATE repos SET sync_status = ?, last_sync_at = CURRENT_TIMESTAMP WHERE id = ?", status, id)
|
|
return err
|
|
}
|
|
|
|
func GetRepoByFullName(serverID int64, fullName string) (*models.Repo, error) {
|
|
var r models.Repo
|
|
err := DB.QueryRow(`
|
|
SELECT id, server_id, name, full_name, clone_url, local_path, size, last_sync_at, sync_status, created_at
|
|
FROM repos WHERE server_id = ? AND full_name = ?
|
|
`, serverID, fullName).Scan(&r.ID, &r.ServerID, &r.Name, &r.FullName, &r.CloneURL, &r.LocalPath, &r.Size, &r.LastSyncAt, &r.SyncStatus, &r.CreatedAt)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return &r, err
|
|
}
|
|
|
|
// Sync log operations
|
|
func CreateSyncLog(log *models.SyncLog) error {
|
|
result, err := DB.Exec(`
|
|
INSERT INTO sync_logs (server_id, repo_id, status, message, started_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`, log.ServerID, log.RepoID, log.Status, log.Message, log.StartedAt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
id, err := result.LastInsertId()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
log.ID = id
|
|
return nil
|
|
}
|
|
|
|
func UpdateSyncLog(id int64, status, message string, finishedAt *time.Time) error {
|
|
_, err := DB.Exec(`
|
|
UPDATE sync_logs SET status = ?, message = ?, finished_at = ? WHERE id = ?
|
|
`, status, message, finishedAt, id)
|
|
return err
|
|
}
|
|
|
|
func GetSyncLogs(serverID int64, limit, offset int) ([]models.SyncLog, error) {
|
|
query := `
|
|
SELECT id, server_id, repo_id, status, message, started_at, finished_at
|
|
FROM sync_logs
|
|
`
|
|
args := []interface{}{}
|
|
|
|
if serverID > 0 {
|
|
query += " WHERE server_id = ?"
|
|
args = append(args, serverID)
|
|
}
|
|
|
|
query += " ORDER BY started_at DESC LIMIT ? OFFSET ?"
|
|
args = append(args, limit, offset)
|
|
|
|
rows, err := DB.Query(query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var logs []models.SyncLog
|
|
for rows.Next() {
|
|
var l models.SyncLog
|
|
err := rows.Scan(&l.ID, &l.ServerID, &l.RepoID, &l.Status, &l.Message, &l.StartedAt, &l.FinishedAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
logs = append(logs, l)
|
|
}
|
|
return logs, nil
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add missing import**
|
|
|
|
Edit `internal/database/database.go`, add `"time"` to imports:
|
|
|
|
```go
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
_ "github.com/mattn/go-sqlite3"
|
|
"gitm/internal/config"
|
|
"gitm/internal/models"
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 3: Test compilation**
|
|
|
|
```bash
|
|
go build -o /tmp/gitm-test .
|
|
```
|
|
|
|
Expected: Success, binary created
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add internal/database/
|
|
git commit -m "feat: implement SQLite database layer"
|
|
```
|
|
|
|
***
|
|
|
|
### Task 4: Implement JWT Authentication
|
|
|
|
**Files:**
|
|
|
|
- Create: `internal/middleware/auth.go`
|
|
- Create: `internal/handler/auth.go`
|
|
- [ ] **Step 1: Create auth middleware**
|
|
|
|
Create `internal/middleware/auth.go`:
|
|
|
|
```go
|
|
package middleware
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
type Claims struct {
|
|
UserID string `json:"user_id"`
|
|
jwt.RegisteredClaims
|
|
}
|
|
|
|
var jwtSecret = []byte("your-secret-key-change-this")
|
|
|
|
func SetJWTSecret(secret string) {
|
|
jwtSecret = []byte(secret)
|
|
}
|
|
|
|
// GenerateToken creates a new JWT token
|
|
func GenerateToken(userID string) (string, error) {
|
|
claims := Claims{
|
|
UserID: userID,
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
},
|
|
}
|
|
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
return token.SignedString(jwtSecret)
|
|
}
|
|
|
|
// ValidateToken parses and validates a JWT token
|
|
func ValidateToken(tokenString string) (*Claims, error) {
|
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
|
return jwtSecret, nil
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
|
return claims, nil
|
|
}
|
|
|
|
return nil, jwt.ErrSignatureInvalid
|
|
}
|
|
|
|
// AuthMiddleware validates JWT tokens
|
|
func AuthMiddleware() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
authHeader := c.GetHeader("Authorization")
|
|
|
|
if authHeader == "" {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
parts := strings.SplitN(authHeader, " ", 2)
|
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization format"})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
claims, err := ValidateToken(parts[1])
|
|
if err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
c.Set("user_id", claims.UserID)
|
|
c.Next()
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add missing import**
|
|
|
|
Edit `internal/middleware/auth.go`, add `"time"` to imports:
|
|
|
|
```go
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 3: Create auth handler**
|
|
|
|
Create `internal/handler/auth.go`:
|
|
|
|
```go
|
|
package handler
|
|
|
|
import (
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gitm/internal/database"
|
|
"gitm/internal/middleware"
|
|
)
|
|
|
|
type LoginRequest struct {
|
|
Password string `json:"password" binding:"required"`
|
|
}
|
|
|
|
type LoginResponse struct {
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
type SettingsResponse struct {
|
|
AdminPassword string `json:"admin_password,omitempty"`
|
|
ListenAddr string `json:"listen_addr"`
|
|
ReposDir string `json:"repos_dir"`
|
|
MaxConcurrent int `json:"max_concurrent"`
|
|
}
|
|
|
|
type UpdateSettingsRequest struct {
|
|
AdminPassword string `json:"admin_password,omitempty"`
|
|
ListenAddr string `json:"listen_addr,omitempty"`
|
|
ReposDir string `json:"repos_dir,omitempty"`
|
|
MaxConcurrent *int `json:"max_concurrent,omitempty"`
|
|
}
|
|
|
|
// HandleLogin handles password authentication
|
|
func HandleLogin(c *gin.Context) {
|
|
var req LoginRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Password required"})
|
|
return
|
|
}
|
|
|
|
// Get stored password hash
|
|
hashedPassword, err := database.GetSetting("admin_password")
|
|
if err != nil || hashedPassword == "" {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Server not initialized"})
|
|
return
|
|
}
|
|
|
|
// Verify password
|
|
if err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(req.Password)); err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid password"})
|
|
return
|
|
}
|
|
|
|
// Generate token
|
|
token, err := middleware.GenerateToken("admin")
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, LoginResponse{Token: token})
|
|
}
|
|
|
|
// HandleGetSettings returns current settings (with password masked)
|
|
func HandleGetSettings(c *gin.Context) {
|
|
listenAddr, _ := database.GetSetting("listen_addr")
|
|
if listenAddr == "" {
|
|
listenAddr = ":9000"
|
|
}
|
|
|
|
reposDir, _ := database.GetSetting("repos_dir")
|
|
maxConcurrent, _ := database.GetSetting("max_concurrent")
|
|
|
|
maxCon := 3
|
|
if maxConcurrent != "" {
|
|
fmt.Sscanf(maxConcurrent, "%d", &maxCon)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, SettingsResponse{
|
|
AdminPassword: "********", // Masked
|
|
ListenAddr: listenAddr,
|
|
ReposDir: reposDir,
|
|
MaxConcurrent: maxCon,
|
|
})
|
|
}
|
|
|
|
// HandleUpdateSettings updates server settings
|
|
func HandleUpdateSettings(c *gin.Context) {
|
|
var req UpdateSettingsRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Update password if provided
|
|
if req.AdminPassword != "" && req.AdminPassword != "********" {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(req.AdminPassword), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
|
|
return
|
|
}
|
|
if err := database.SetSetting("admin_password", string(hash)); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save password"})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Update other settings
|
|
if req.ListenAddr != "" {
|
|
database.SetSetting("listen_addr", req.ListenAddr)
|
|
}
|
|
if req.ReposDir != "" {
|
|
database.SetSetting("repos_dir", req.ReposDir)
|
|
}
|
|
if req.MaxConcurrent != nil {
|
|
database.SetSetting("max_concurrent", fmt.Sprintf("%d", *req.MaxConcurrent))
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Settings updated"})
|
|
}
|
|
|
|
// HandleInit initializes the admin password
|
|
func HandleInit(c *gin.Context) {
|
|
var req LoginRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Password required"})
|
|
return
|
|
}
|
|
|
|
// Check if already initialized
|
|
hashedPassword, _ := database.GetSetting("admin_password")
|
|
if hashedPassword != "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Already initialized"})
|
|
return
|
|
}
|
|
|
|
// Hash and store password
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
|
|
return
|
|
}
|
|
|
|
if err := database.SetSetting("admin_password", string(hash)); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save password"})
|
|
return
|
|
}
|
|
|
|
// Set default values
|
|
database.SetSetting("listen_addr", ":9000")
|
|
database.SetSetting("max_concurrent", "3")
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Initialized successfully"})
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Add missing imports**
|
|
|
|
Edit `internal/handler/auth.go`, add imports:
|
|
|
|
```go
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gitm/internal/database"
|
|
"gitm/internal/middleware"
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 5: Test compilation**
|
|
|
|
```bash
|
|
go build -o /tmp/gitm-test .
|
|
```
|
|
|
|
Expected: Success
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add internal/middleware/ internal/handler/
|
|
git commit -m "feat: implement JWT authentication middleware and handlers"
|
|
```
|
|
|
|
***
|
|
|
|
## Phase 2: Gitea API Integration
|
|
|
|
### Task 5: Implement Gitea API Client
|
|
|
|
**Files:**
|
|
|
|
- Create: `internal/gitea/client.go`
|
|
- Create: `internal/gitea/types.go`
|
|
- [ ] **Step 1: Create Gitea types**
|
|
|
|
Create `internal/gitea/types.go`:
|
|
|
|
```go
|
|
package gitea
|
|
|
|
// GiteaUser represents a Gitea user
|
|
type GiteaUser struct {
|
|
ID int64 `json:"id"`
|
|
Login string `json:"login"`
|
|
FullName string `json:"full_name"`
|
|
Email string `json:"email"`
|
|
}
|
|
|
|
// GiteaRepo represents a Gitea repository
|
|
type GiteaRepo struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
FullName string `json:"full_name"`
|
|
CloneURL string `json:"clone_url"`
|
|
SSHURL string `json:"ssh_url"`
|
|
Private bool `json:"private"`
|
|
Size int64 `json:"size"`
|
|
Updated string `json:"updated_at"`
|
|
Owner *GiteaUser `json:"owner"`
|
|
}
|
|
|
|
// GiteaSearchResponse represents the search API response
|
|
type GiteaSearchResponse struct {
|
|
OK bool `json:"ok"`
|
|
Data []GiteaRepo `json:"data"`
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create Gitea client**
|
|
|
|
Create `internal/gitea/client.go`:
|
|
|
|
```go
|
|
package gitea
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
)
|
|
|
|
// Client represents a Gitea API client
|
|
type Client struct {
|
|
baseURL *url.URL
|
|
httpClient *http.Client
|
|
token string
|
|
}
|
|
|
|
// NewClient creates a new Gitea API client
|
|
func NewClient(serverURL, token string) (*Client, error) {
|
|
u, err := url.Parse(serverURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid server URL: %w", err)
|
|
}
|
|
|
|
return &Client{
|
|
baseURL: u,
|
|
httpClient: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
},
|
|
token: token,
|
|
}, nil
|
|
}
|
|
|
|
// do performs an HTTP request with authentication
|
|
func (c *Client) do(path string) (*http.Response, error) {
|
|
// Build URL with token as query parameter
|
|
apiURL := c.baseURL.Join(path)
|
|
apiURL.RawQuery = url.Values{"token": {c.token}}.Encode()
|
|
|
|
resp, err := c.httpClient.Get(apiURL.String())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("request failed: %w", err)
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// ValidateToken checks if the current token is valid
|
|
func (c *Client) ValidateToken() (*GiteaUser, error) {
|
|
resp, err := c.do("/api/v1/user")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("authentication failed: %s - %s", resp.Status, string(body))
|
|
}
|
|
|
|
var user GiteaUser
|
|
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
|
|
return &user, nil
|
|
}
|
|
|
|
// SearchRepos searches for repositories, paginated
|
|
func (c *Client) SearchRepos(page, limit int) ([]GiteaRepo, error) {
|
|
resp, err := c.do(fmt.Sprintf("/api/v1/repos/search?page=%d&limit=%d", page, limit))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("search failed: %s - %s", resp.Status, string(body))
|
|
}
|
|
|
|
var searchResp GiteaSearchResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
|
|
return searchResp.Data, nil
|
|
}
|
|
|
|
// GetAllRepos fetches all repositories by paginating through results
|
|
func (c *Client) GetAllRepos() ([]GiteaRepo, error) {
|
|
var allRepos []GiteaRepo
|
|
page := 1
|
|
limit := 50
|
|
|
|
for {
|
|
repos, err := c.SearchRepos(page, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(repos) == 0 {
|
|
break
|
|
}
|
|
|
|
allRepos = append(allRepos, repos...)
|
|
|
|
if len(repos) < limit {
|
|
break
|
|
}
|
|
|
|
page++
|
|
}
|
|
|
|
return allRepos, nil
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Test compilation**
|
|
|
|
```bash
|
|
go build -o /tmp/gitm-test .
|
|
```
|
|
|
|
Expected: Success
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add internal/gitea/
|
|
git commit -m "feat: implement Gitea API client"
|
|
```
|
|
|
|
***
|
|
|
|
### Task 6: Implement Sync Engine
|
|
|
|
**Files:**
|
|
|
|
- Create: `internal/sync/engine.go`
|
|
- Create: `internal/sync/scheduler.go`
|
|
- [ ] **Step 1: Create sync engine**
|
|
|
|
Create `internal/sync/engine.go`:
|
|
|
|
```go
|
|
package sync
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"gitm/internal/database"
|
|
"gitm/internal/gitea"
|
|
)
|
|
|
|
// Engine handles repository synchronization
|
|
type Engine struct {
|
|
maxConcurrent int
|
|
mu sync.Mutex
|
|
activeTasks map[int64]string // serverID -> taskID
|
|
}
|
|
|
|
// NewEngine creates a new sync engine
|
|
func NewEngine(maxConcurrent int) *Engine {
|
|
return &Engine{
|
|
maxConcurrent: maxConcurrent,
|
|
activeTasks: make(map[int64]string),
|
|
}
|
|
}
|
|
|
|
// SyncServer syncs all repositories from a Gitea server
|
|
func (e *Engine) SyncServer(serverID int64) error {
|
|
e.mu.Lock()
|
|
if _, active := e.activeTasks[serverID]; active {
|
|
e.mu.Unlock()
|
|
return fmt.Errorf("sync already in progress for server %d", serverID)
|
|
}
|
|
taskID := fmt.Sprintf("%d-%d", serverID, time.Now().Unix())
|
|
e.activeTasks[serverID] = taskID
|
|
e.mu.Unlock()
|
|
|
|
defer func() {
|
|
e.mu.Lock()
|
|
delete(e.activeTasks, serverID)
|
|
e.mu.Unlock()
|
|
}()
|
|
|
|
// Get server config
|
|
server, err := database.GetServer(serverID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get server: %w", err)
|
|
}
|
|
|
|
// Create sync log
|
|
log := &database.SyncLog{
|
|
ServerID: serverID,
|
|
Status: "in_progress",
|
|
Message: "Starting sync",
|
|
StartedAt: time.Now(),
|
|
}
|
|
if err := database.CreateSyncLog(log); err != nil {
|
|
return fmt.Errorf("failed to create sync log: %w", err)
|
|
}
|
|
|
|
// Create Gitea client
|
|
client, err := gitea.NewClient(server.URL, server.Token)
|
|
if err != nil {
|
|
e.finishLog(log, "failed", err.Error())
|
|
return fmt.Errorf("failed to create client: %w", err)
|
|
}
|
|
|
|
// Validate token
|
|
if _, err := client.ValidateToken(); err != nil {
|
|
e.finishLog(log, "failed", fmt.Sprintf("Authentication failed: %v", err))
|
|
return fmt.Errorf("token validation failed: %w", err)
|
|
}
|
|
|
|
// Get all repos
|
|
giteaRepos, err := client.GetAllRepos()
|
|
if err != nil {
|
|
e.finishLog(log, "failed", fmt.Sprintf("Failed to fetch repos: %v", err))
|
|
return fmt.Errorf("failed to get repos: %w", err)
|
|
}
|
|
|
|
// Get repos directory
|
|
reposDir, _ := database.GetSetting("repos_dir")
|
|
if reposDir == "" {
|
|
reposDir = "./data/repos"
|
|
}
|
|
serverDir := filepath.Join(reposDir, fmt.Sprintf("server_%d_%s", serverID, server.Name))
|
|
if err := os.MkdirAll(serverDir, 0755); err != nil {
|
|
e.finishLog(log, "failed", fmt.Sprintf("Failed to create directory: %v", err))
|
|
return fmt.Errorf("failed to create server directory: %w", err)
|
|
}
|
|
|
|
// Sync each repo
|
|
successCount := 0
|
|
failedCount := 0
|
|
sem := make(chan struct{}, e.maxConcurrent)
|
|
var wg sync.WaitGroup
|
|
var resultsMu sync.Mutex
|
|
|
|
for _, gr := range giteaRepos {
|
|
wg.Add(1)
|
|
go func(giteaRepo gitea.GiteaRepo) {
|
|
defer wg.Done()
|
|
sem <- struct{}{}
|
|
defer func() { <-sem }()
|
|
|
|
// Check if repo exists
|
|
existingRepo, _ := database.GetRepoByFullName(serverID, giteaRepo.FullName)
|
|
|
|
// Determine local path
|
|
ownerName := giteaRepo.FullName
|
|
if giteaRepo.Owner != nil {
|
|
ownerName = filepath.Join(giteaRepo.Owner.Login, giteaRepo.Name)
|
|
}
|
|
localPath := filepath.Join(serverDir, giteaRepo.FullName+".git")
|
|
|
|
// Clone or fetch
|
|
var err error
|
|
if existingRepo == nil {
|
|
err = e.cloneMirror(giteaRepo.CloneURL, localPath)
|
|
if err == nil {
|
|
// Create repo record
|
|
repo := &database.Repo{
|
|
ServerID: serverID,
|
|
Name: giteaRepo.Name,
|
|
FullName: giteaRepo.FullName,
|
|
CloneURL: giteaRepo.CloneURL,
|
|
LocalPath: localPath,
|
|
SyncStatus: "success",
|
|
}
|
|
database.CreateOrUpdateRepo(repo)
|
|
}
|
|
} else {
|
|
err = e.fetchMirror(localPath)
|
|
if err == nil {
|
|
database.UpdateRepoSyncStatus(existingRepo.ID, "success")
|
|
}
|
|
}
|
|
|
|
resultsMu.Lock()
|
|
if err != nil {
|
|
failedCount++
|
|
// Create failure log
|
|
failLog := &database.SyncLog{
|
|
ServerID: serverID,
|
|
RepoID: getRepoID(serverID, giteaRepo.FullName),
|
|
Status: "failed",
|
|
Message: err.Error(),
|
|
StartedAt: time.Now(),
|
|
}
|
|
db.CreateSyncLog(failLog)
|
|
} else {
|
|
successCount++
|
|
}
|
|
resultsMu.Unlock()
|
|
}(gr)
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
// Update server last sync
|
|
now := time.Now()
|
|
database.UpdateServerLastSync(serverID, now)
|
|
|
|
// Finish log
|
|
message := fmt.Sprintf("Sync completed: %d succeeded, %d failed", successCount, failedCount)
|
|
e.finishLog(log, "success", message)
|
|
|
|
return nil
|
|
}
|
|
|
|
// cloneMirror performs a git clone --mirror
|
|
func (e *Engine) cloneMirror(url, path string) error {
|
|
if _, err := os.Stat(path); err == nil {
|
|
// Directory exists, skip
|
|
return nil
|
|
}
|
|
|
|
cmd := exec.Command("git", "clone", "--mirror", url, path)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("clone failed: %w - %s", err, string(output))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// fetchMirror performs a git fetch --all --prune in a mirror repo
|
|
func (e *Engine) fetchMirror(path string) error {
|
|
cmd := exec.Command("git", "--git-dir", path, "fetch", "--all", "--prune")
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("fetch failed: %w - %s", err, string(output))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// finishLog updates a sync log entry
|
|
func (e *Engine) finishLog(log *database.SyncLog, status, message string) {
|
|
now := time.Now()
|
|
log.Status = status
|
|
log.Message = message
|
|
log.FinishedAt = &now
|
|
database.UpdateSyncLog(log.ID, status, message, log.FinishedAt)
|
|
}
|
|
|
|
// IsSyncing checks if a server is currently syncing
|
|
func (e *Engine) IsSyncing(serverID int64) bool {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
_, active := e.activeTasks[serverID]
|
|
return active
|
|
}
|
|
|
|
// helper to get repo ID for logging
|
|
func getRepoID(serverID int64, fullName string) *int64 {
|
|
repo, _ := database.GetRepoByFullName(serverID, fullName)
|
|
if repo != nil {
|
|
return &repo.ID
|
|
}
|
|
return nil
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Fix imports and issues**
|
|
|
|
Edit `internal/sync/engine.go`:
|
|
|
|
```go
|
|
package sync
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"gitm/internal/database"
|
|
db "gitm/internal/database"
|
|
"gitm/internal/gitea"
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 3: Create scheduler**
|
|
|
|
Create `internal/sync/scheduler.go`:
|
|
|
|
```go
|
|
package sync
|
|
|
|
import (
|
|
"log"
|
|
"time"
|
|
|
|
"github.com/robfig/cron/v3"
|
|
"gitm/internal/database"
|
|
)
|
|
|
|
// Scheduler manages scheduled sync tasks
|
|
type Scheduler struct {
|
|
cron *cron.Cron
|
|
engine *Engine
|
|
serverJobs map[int64]cron.EntryID
|
|
}
|
|
|
|
// NewScheduler creates a new scheduler
|
|
func NewScheduler(engine *Engine) *Scheduler {
|
|
return &Scheduler{
|
|
cron: cron.New(),
|
|
engine: engine,
|
|
serverJobs: make(map[int64]cron.EntryID),
|
|
}
|
|
}
|
|
|
|
// Start starts the scheduler
|
|
func (s *Scheduler) Start() {
|
|
s.cron.Start()
|
|
log.Println("Scheduler started")
|
|
}
|
|
|
|
// Stop stops the scheduler
|
|
func (s *Scheduler) Stop() {
|
|
s.cron.Stop()
|
|
log.Println("Scheduler stopped")
|
|
}
|
|
|
|
// UpdateServer updates or removes a scheduled job for a server
|
|
func (s *Scheduler) UpdateServer(server *database.GiteaServer) {
|
|
// Remove existing job
|
|
if entryID, exists := s.serverJobs[server.ID]; exists {
|
|
s.cron.Remove(entryID)
|
|
delete(s.serverJobs, server.ID)
|
|
}
|
|
|
|
// Skip if manual sync only or disabled
|
|
if server.SyncInterval <= 0 || server.Status != "active" {
|
|
return
|
|
}
|
|
|
|
// Add new job - run every N minutes
|
|
schedule := fmt.Sprintf("*/%d * * * *", server.SyncInterval)
|
|
entryID, err := s.cron.AddFunc(schedule, func() {
|
|
log.Printf("Scheduled sync triggered for server %d (%s)", server.ID, server.Name)
|
|
if err := s.engine.SyncServer(server.ID); err != nil {
|
|
log.Printf("Scheduled sync failed for server %d: %v", server.ID, err)
|
|
}
|
|
})
|
|
|
|
if err != nil {
|
|
log.Printf("Failed to schedule job for server %d: %v", server.ID, err)
|
|
return
|
|
}
|
|
|
|
s.serverJobs[server.ID] = entryID
|
|
log.Printf("Scheduled sync every %d minutes for server %d (%s)", server.SyncInterval, server.ID, server.Name)
|
|
}
|
|
|
|
// RemoveServer removes a scheduled job for a server
|
|
func (s *Scheduler) RemoveServer(serverID int64) {
|
|
if entryID, exists := s.serverJobs[serverID]; exists {
|
|
s.cron.Remove(entryID)
|
|
delete(s.serverJobs, serverID)
|
|
log.Printf("Removed scheduled job for server %d", serverID)
|
|
}
|
|
}
|
|
|
|
// ReloadAll reloads all server schedules from database
|
|
func (s *Scheduler) ReloadAll() error {
|
|
servers, err := database.GetServers()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, server := range servers {
|
|
s.UpdateServer(&server)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Add missing import to scheduler**
|
|
|
|
Edit `internal/sync/scheduler.go`:
|
|
|
|
```go
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"time"
|
|
|
|
"github.com/robfig/cron/v3"
|
|
"gitm/internal/database"
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 5: Test compilation**
|
|
|
|
```bash
|
|
go build -o /tmp/gitm-test .
|
|
```
|
|
|
|
Expected: Success
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add internal/sync/
|
|
git commit -m "feat: implement sync engine and scheduler"
|
|
```
|
|
|
|
***
|
|
|
|
## Phase 3: HTTP Handlers
|
|
|
|
### Task 7: Implement Server Management Handlers
|
|
|
|
**Files:**
|
|
|
|
- Create: `internal/handler/server.go`
|
|
- [ ] **Step 1: Create server handler**
|
|
|
|
Create `internal/handler/server.go`:
|
|
|
|
```go
|
|
package handler
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gitm/internal/database"
|
|
"gitm/internal/gitea"
|
|
"gitm/internal/sync"
|
|
)
|
|
|
|
// HandleListServers returns all configured Gitea servers
|
|
func HandleListServers(c *gin.Context) {
|
|
servers, err := database.GetServers()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, servers)
|
|
}
|
|
|
|
// CreateServerRequest defines the request to create a server
|
|
type CreateServerRequest struct {
|
|
Name string `json:"name" binding:"required"`
|
|
URL string `json:"url" binding:"required"`
|
|
Token string `json:"token" binding:"required"`
|
|
SyncInterval int `json:"sync_interval"`
|
|
}
|
|
|
|
// HandleCreateServer adds a new Gitea server
|
|
func HandleCreateServer(c *gin.Context) {
|
|
var req CreateServerRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
server := &database.GiteaServer{
|
|
Name: req.Name,
|
|
URL: req.URL,
|
|
Token: req.Token,
|
|
SyncInterval: req.SyncInterval,
|
|
Status: "active",
|
|
}
|
|
|
|
if err := database.CreateServer(server); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, server)
|
|
}
|
|
|
|
// UpdateServerRequest defines the request to update a server
|
|
type UpdateServerRequest struct {
|
|
Name *string `json:"name"`
|
|
URL *string `json:"url"`
|
|
Token *string `json:"token"`
|
|
SyncInterval *int `json:"sync_interval"`
|
|
Status *string `json:"status"`
|
|
}
|
|
|
|
// HandleUpdateServer updates a Gitea server configuration
|
|
func HandleUpdateServer(c *gin.Context) {
|
|
idStr := c.Param("id")
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"})
|
|
return
|
|
}
|
|
|
|
server, err := database.GetServer(id)
|
|
if err != nil || server == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Server not found"})
|
|
return
|
|
}
|
|
|
|
var req UpdateServerRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if req.Name != nil {
|
|
server.Name = *req.Name
|
|
}
|
|
if req.URL != nil {
|
|
server.URL = *req.URL
|
|
}
|
|
if req.Token != nil {
|
|
server.Token = *req.Token
|
|
}
|
|
if req.SyncInterval != nil {
|
|
server.SyncInterval = *req.SyncInterval
|
|
}
|
|
if req.Status != nil {
|
|
server.Status = *req.Status
|
|
}
|
|
|
|
if err := database.UpdateServer(server); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, server)
|
|
}
|
|
|
|
// HandleDeleteServer removes a Gitea server
|
|
func HandleDeleteServer(c *gin.Context) {
|
|
idStr := c.Param("id")
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"})
|
|
return
|
|
}
|
|
|
|
if err := database.DeleteServer(id); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Server deleted"})
|
|
}
|
|
|
|
// TestConnectionRequest defines the request to test a server connection
|
|
type TestConnectionRequest struct {
|
|
URL string `json:"url" binding:"required"`
|
|
Token string `json:"token" binding:"required"`
|
|
}
|
|
|
|
// HandleTestConnection tests connection to a Gitea server
|
|
func HandleTestConnection(c *gin.Context) {
|
|
var req TestConnectionRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
client, err := gitea.NewClient(req.URL, req.Token)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
user, err := client.ValidateToken()
|
|
if err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication failed"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "Connection successful",
|
|
"user": user.Login,
|
|
})
|
|
}
|
|
|
|
// HandleListRepos returns repositories for a server
|
|
func HandleListRepos(c *gin.Context) {
|
|
idStr := c.Param("id")
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"})
|
|
return
|
|
}
|
|
|
|
repos, err := database.GetReposByServer(id)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, repos)
|
|
}
|
|
|
|
// HandleDiscoverRepos triggers repo discovery from Gitea API
|
|
func HandleDiscoverRepos(c *gin.Context) {
|
|
idStr := c.Param("id")
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"})
|
|
return
|
|
}
|
|
|
|
server, err := database.GetServer(id)
|
|
if err != nil || server == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Server not found"})
|
|
return
|
|
}
|
|
|
|
client, err := gitea.NewClient(server.URL, server.Token)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
giteaRepos, err := client.GetAllRepos()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Get repos directory
|
|
reposDir, _ := database.GetSetting("repos_dir")
|
|
if reposDir == "" {
|
|
reposDir = "./data/repos"
|
|
}
|
|
serverDir := reposDir + "/" + strconv.FormatInt(id, 10) + "_" + server.Name
|
|
|
|
discovered := 0
|
|
for _, gr := range giteaRepos {
|
|
// Check if already exists
|
|
existing, _ := database.GetRepoByFullName(id, gr.FullName)
|
|
if existing == nil {
|
|
repo := &database.Repo{
|
|
ServerID: id,
|
|
Name: gr.Name,
|
|
FullName: gr.FullName,
|
|
CloneURL: gr.CloneURL,
|
|
LocalPath: serverDir + "/" + gr.FullName + ".git",
|
|
SyncStatus: "pending",
|
|
}
|
|
database.CreateOrUpdateRepo(repo)
|
|
discovered++
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": fmt.Sprintf("Discovered %d new repositories", discovered),
|
|
"total_repos": len(giteaRepos),
|
|
"new_repos": discovered,
|
|
})
|
|
}
|
|
|
|
// HandleSyncServer triggers a sync for a specific server
|
|
func HandleSyncServer(c *gin.Context, engine *sync.Engine) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
idStr := c.Param("id")
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"})
|
|
return
|
|
}
|
|
|
|
if engine.IsSyncing(id) {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "Sync already in progress"})
|
|
return
|
|
}
|
|
|
|
go engine.SyncServer(id)
|
|
|
|
c.JSON(http.StatusAccepted, gin.H{
|
|
"message": "Sync started",
|
|
"server_id": id,
|
|
})
|
|
}
|
|
}
|
|
|
|
// HandleSyncAll triggers sync for all active servers
|
|
func HandleSyncAll(c *gin.Context, engine *sync.Engine) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
servers, err := database.GetServers()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
started := 0
|
|
for _, server := range servers {
|
|
if server.Status == "active" && !engine.IsSyncing(server.ID) {
|
|
go engine.SyncServer(server.ID)
|
|
started++
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusAccepted, gin.H{
|
|
"message": fmt.Sprintf("Started sync for %d servers", started),
|
|
})
|
|
}
|
|
}
|
|
|
|
// HandleGetSyncStatus returns sync status for a server
|
|
func HandleGetSyncStatus(c *gin.Context, engine *sync.Engine) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
idStr := c.Param("id")
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"})
|
|
return
|
|
}
|
|
|
|
if engine.IsSyncing(id) {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"server_id": id,
|
|
"status": "syncing",
|
|
})
|
|
} else {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"server_id": id,
|
|
"status": "idle",
|
|
})
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add missing import**
|
|
|
|
Edit `internal/handler/server.go`, add `"fmt"` to imports:
|
|
|
|
```go
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gitm/internal/database"
|
|
"gitm/internal/gitea"
|
|
"gitm/internal/sync"
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 3: Test compilation**
|
|
|
|
```bash
|
|
go build -o /tmp/gitm-test .
|
|
```
|
|
|
|
Expected: Success
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add internal/handler/server.go
|
|
git commit -m "feat: implement server management handlers"
|
|
```
|
|
|
|
***
|
|
|
|
### Task 8: Implement Log and Stats Handlers
|
|
|
|
**Files:**
|
|
|
|
- Create: `internal/handler/log.go`
|
|
- [ ] **Step 1: Create log handler**
|
|
|
|
Create `internal/handler/log.go`:
|
|
|
|
```go
|
|
package handler
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gitm/internal/database"
|
|
)
|
|
|
|
// HandleGetSyncLogs returns sync logs with pagination
|
|
func HandleGetSyncLogs(c *gin.Context) {
|
|
serverIDStr := c.Query("server_id")
|
|
pageStr := c.DefaultQuery("page", "1")
|
|
limitStr := c.DefaultQuery("limit", "50")
|
|
|
|
page, _ := strconv.Atoi(pageStr)
|
|
limit, _ := strconv.Atoi(limitStr)
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
|
|
var serverID int64 = 0
|
|
if serverIDStr != "" {
|
|
serverID, _ = strconv.ParseInt(serverIDStr, 10, 64)
|
|
}
|
|
|
|
offset := (page - 1) * limit
|
|
|
|
logs, err := database.GetSyncLogs(serverID, limit, offset)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"data": logs,
|
|
"page": page,
|
|
"limit": limit,
|
|
"count": len(logs),
|
|
})
|
|
}
|
|
|
|
// HandleGetStats returns dashboard statistics
|
|
func HandleGetStats(c *gin.Context) {
|
|
servers, err := database.GetServers()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
serverCount := len(servers)
|
|
repoCount := 0
|
|
totalSize := int64(0)
|
|
activeServers := 0
|
|
|
|
for _, server := range servers {
|
|
if server.Status == "active" {
|
|
activeServers++
|
|
}
|
|
|
|
repos, err := database.GetReposByServer(server.ID)
|
|
if err == nil {
|
|
repoCount += len(repos)
|
|
for _, repo := range repos {
|
|
totalSize += repo.Size
|
|
}
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"server_count": serverCount,
|
|
"active_servers": activeServers,
|
|
"repo_count": repoCount,
|
|
"total_size": totalSize,
|
|
})
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Test compilation**
|
|
|
|
```bash
|
|
go build -o /tmp/gitm-test .
|
|
```
|
|
|
|
Expected: Success
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add internal/handler/log.go
|
|
git commit -m "feat: implement sync log and stats handlers"
|
|
```
|
|
|
|
***
|
|
|
|
### Task 9: Wire Up Main Server
|
|
|
|
**Files:**
|
|
|
|
- Modify: `main.go`
|
|
- [ ] **Step 1: Update main.go with full server implementation**
|
|
|
|
Replace `main.go` with:
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gitm/internal/config"
|
|
"gitm/internal/database"
|
|
"gitm/internal/handler"
|
|
"gitm/internal/middleware"
|
|
"gitm/internal/sync"
|
|
)
|
|
|
|
var (
|
|
flagAddr = flag.String("addr", "", "Listen address")
|
|
flagDataDir = flag.String("data", "", "Data directory")
|
|
flagInit = flag.Bool("init", false, "Initialize database and set password")
|
|
)
|
|
|
|
func main() {
|
|
flag.Parse()
|
|
|
|
cfg := config.Get()
|
|
|
|
if *flagDataDir != "" {
|
|
cfg.SetDataDir(*flagDataDir)
|
|
}
|
|
if *flagAddr != "" {
|
|
cfg.ListenAddr = *flagAddr
|
|
}
|
|
|
|
// Ensure directories exist
|
|
if err := cfg.EnsureDirs(); err != nil {
|
|
log.Fatalf("Failed to create directories: %v", err)
|
|
}
|
|
|
|
// Initialize database
|
|
if err := database.Initialize(cfg.DBPath); err != nil {
|
|
log.Fatalf("Failed to initialize database: %v", err)
|
|
}
|
|
defer database.Close()
|
|
|
|
// Load settings to config
|
|
if listenAddr, err := database.GetSetting("listen_addr"); err == nil && listenAddr != "" {
|
|
cfg.ListenAddr = listenAddr
|
|
}
|
|
|
|
// Get max concurrent setting
|
|
maxConcurrent := 3
|
|
if maxStr, err := database.GetSetting("max_concurrent"); err == nil && maxStr != "" {
|
|
fmt.Sscanf(maxStr, "%d", &maxConcurrent)
|
|
}
|
|
|
|
// Initialize components
|
|
engine := sync.NewEngine(maxConcurrent)
|
|
scheduler := sync.NewScheduler(engine)
|
|
|
|
// Init mode
|
|
if *flagInit {
|
|
runInitMode()
|
|
return
|
|
}
|
|
|
|
// Check if initialized
|
|
if _, err := database.GetSetting("admin_password"); err != nil {
|
|
fmt.Println("Not initialized. Please run with --init flag first.")
|
|
return
|
|
}
|
|
|
|
// Set JWT secret from a fixed key (in production, derive from password)
|
|
middleware.SetJWTSecret("gitm-default-secret-change-in-production")
|
|
|
|
// Reload scheduler with existing servers
|
|
scheduler.ReloadAll()
|
|
scheduler.Start()
|
|
defer scheduler.Stop()
|
|
|
|
// Start server
|
|
log.Printf("GitM starting on %s", cfg.ListenAddr)
|
|
log.Fatal(runServer(cfg, engine, scheduler))
|
|
}
|
|
|
|
func runInitMode() {
|
|
var password string
|
|
fmt.Print("Enter admin password: ")
|
|
fmt.Scanln(&password)
|
|
|
|
if password == "" {
|
|
log.Fatal("Password cannot be empty")
|
|
}
|
|
|
|
// Hash password manually for init
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
log.Fatalf("Failed to hash password: %v", err)
|
|
}
|
|
|
|
if err := database.SetSetting("admin_password", string(hash)); err != nil {
|
|
log.Fatalf("Failed to save password: %v", err)
|
|
}
|
|
|
|
// Set defaults
|
|
database.SetSetting("listen_addr", ":9000")
|
|
database.SetSetting("max_concurrent", "3")
|
|
|
|
fmt.Println("Initialized successfully!")
|
|
}
|
|
|
|
func runServer(cfg *config.Config, engine *sync.Engine, scheduler *sync.Scheduler) error {
|
|
gin.SetMode(gin.ReleaseMode)
|
|
r := gin.Default()
|
|
|
|
// API routes
|
|
api := r.Group("/api")
|
|
{
|
|
// Public routes
|
|
api.POST("/login", handler.HandleLogin)
|
|
api.POST("/init", handler.HandleInit)
|
|
|
|
// Protected routes
|
|
protected := api.Group("")
|
|
protected.Use(middleware.AuthMiddleware())
|
|
{
|
|
// Settings
|
|
protected.GET("/settings", handler.HandleGetSettings)
|
|
protected.PUT("/settings", handler.HandleUpdateSettings)
|
|
|
|
// Servers
|
|
protected.GET("/servers", handler.HandleListServers)
|
|
protected.POST("/servers", handler.HandleCreateServer)
|
|
protected.PUT("/servers/:id", handler.HandleUpdateServer)
|
|
protected.DELETE("/servers/:id", handler.HandleDeleteServer)
|
|
protected.POST("/servers/:id/test", handler.HandleTestConnection)
|
|
protected.GET("/servers/:id/repos", handler.HandleListRepos)
|
|
protected.POST("/servers/:id/discover", handler.HandleDiscoverRepos)
|
|
protected.POST("/servers/:id/sync", handler.HandleSyncServer(engine))
|
|
protected.GET("/servers/:id/sync/status", handler.HandleGetSyncStatus(engine))
|
|
|
|
// Sync
|
|
protected.POST("/sync/all", handler.HandleSyncAll(engine))
|
|
|
|
// Logs and stats
|
|
protected.GET("/sync/logs", handler.HandleGetSyncLogs)
|
|
protected.GET("/sync/stats", handler.HandleGetStats)
|
|
}
|
|
}
|
|
|
|
// Serve static files (frontend) - will be added when frontend is built
|
|
// r.Static("/", "./web/dist")
|
|
|
|
return r.Run(cfg.ListenAddr)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add missing imports**
|
|
|
|
Edit `main.go`, add `"golang.org/x/crypto/bcrypt"` to imports:
|
|
|
|
```go
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gitm/internal/config"
|
|
"gitm/internal/database"
|
|
"gitm/internal/handler"
|
|
"gitm/internal/middleware"
|
|
"gitm/internal/sync"
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 3: Test compilation**
|
|
|
|
```bash
|
|
go build -o /tmp/gitm-test .
|
|
```
|
|
|
|
Expected: Success
|
|
|
|
- [ ] **Step 4: Test basic run**
|
|
|
|
```bash
|
|
/tmp/gitm-test --init
|
|
```
|
|
|
|
Expected: Prompts for password (type one, then Enter)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add main.go
|
|
git commit -m "feat: wire up main server with all routes"
|
|
```
|
|
|
|
***
|
|
|
|
## Phase 4: Frontend Implementation
|
|
|
|
### Task 10: Initialize Vue 3 Frontend
|
|
|
|
**Files:**
|
|
|
|
- Create: `web/package.json`
|
|
- Create: `web/vite.config.ts`
|
|
- Create: `web/tsconfig.json`
|
|
- Create: `web/index.html`
|
|
- Create: `web/src/main.ts`
|
|
- Create: `web/src/App.vue`
|
|
- [ ] **Step 1: Create web/package.json**
|
|
|
|
Create `web/package.json`:
|
|
|
|
```json
|
|
{
|
|
"name": "gitm-web",
|
|
"version": "1.0.0",
|
|
"type": "module",
|
|
"scripts": {
|
|
"dev": "vite",
|
|
"build": "vue-tsc && vite build",
|
|
"preview": "vite preview"
|
|
},
|
|
"dependencies": {
|
|
"vue": "^3.4.0",
|
|
"vue-router": "^4.2.5",
|
|
"pinia": "^2.1.7",
|
|
"element-plus": "^2.5.0",
|
|
"@element-plus/icons-vue": "^2.3.1",
|
|
"axios": "^1.6.0"
|
|
},
|
|
"devDependencies": {
|
|
"@vitejs/plugin-vue": "^5.0.0",
|
|
"typescript": "^5.3.0",
|
|
"vue-tsc": "^1.8.27",
|
|
"vite": "^5.0.0"
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create web/vite.config.ts**
|
|
|
|
Create `web/vite.config.ts`:
|
|
|
|
```typescript
|
|
import { defineConfig } from 'vite'
|
|
import vue from '@vitejs/plugin-vue'
|
|
import { resolve } from 'path'
|
|
|
|
export default defineConfig({
|
|
plugins: [vue()],
|
|
resolve: {
|
|
alias: {
|
|
'@': resolve(__dirname, 'src')
|
|
}
|
|
},
|
|
server: {
|
|
port: 5173,
|
|
proxy: {
|
|
'/api': {
|
|
target: 'http://localhost:9000',
|
|
changeOrigin: true
|
|
}
|
|
}
|
|
},
|
|
build: {
|
|
outDir: 'dist',
|
|
assetsDir: 'assets'
|
|
}
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 3: Create web/tsconfig.json**
|
|
|
|
Create `web/tsconfig.json`:
|
|
|
|
```json
|
|
{
|
|
"compilerOptions": {
|
|
"target": "ES2020",
|
|
"useDefineForClassFields": true,
|
|
"module": "ESNext",
|
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
"skipLibCheck": true,
|
|
"moduleResolution": "bundler",
|
|
"allowImportingTsExtensions": true,
|
|
"resolveJsonModule": true,
|
|
"isolatedModules": true,
|
|
"noEmit": true,
|
|
"jsx": "preserve",
|
|
"strict": true,
|
|
"noUnusedLocals": true,
|
|
"noUnusedParameters": true,
|
|
"noFallthroughCasesInSwitch": true,
|
|
"baseUrl": ".",
|
|
"paths": {
|
|
"@/*": ["src/*"]
|
|
}
|
|
},
|
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
|
"references": [{ "path": "./tsconfig.node.json" }]
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Create web/tsconfig.node.json**
|
|
|
|
Create `web/tsconfig.node.json`:
|
|
|
|
```json
|
|
{
|
|
"compilerOptions": {
|
|
"composite": true,
|
|
"skipLibCheck": true,
|
|
"module": "ESNext",
|
|
"moduleResolution": "bundler",
|
|
"allowSyntheticDefaultImports": true
|
|
},
|
|
"include": ["vite.config.ts"]
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Create web/index.html**
|
|
|
|
Create `web/index.html`:
|
|
|
|
```html
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>GitM - Gitea Repository Sync</title>
|
|
</head>
|
|
<body>
|
|
<div id="app"></div>
|
|
<script type="module" src="/src/main.ts"></script>
|
|
</body>
|
|
</html>
|
|
```
|
|
|
|
- [ ] **Step 6: Create web/src/main.ts**
|
|
|
|
Create `web/src/main.ts`:
|
|
|
|
```typescript
|
|
import { createApp } from 'vue'
|
|
import { createPinia } from 'pinia'
|
|
import ElementPlus from 'element-plus'
|
|
import 'element-plus/dist/index.css'
|
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
|
import App from './App.vue'
|
|
import router from './router'
|
|
|
|
const app = createApp(App)
|
|
const pinia = createPinia()
|
|
|
|
// Register icons
|
|
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
|
app.component(key, component)
|
|
}
|
|
|
|
app.use(pinia)
|
|
app.use(router)
|
|
app.use(ElementPlus)
|
|
app.mount('#app')
|
|
```
|
|
|
|
- [ ] **Step 7: Create web/src/App.vue**
|
|
|
|
Create `web/src/App.vue`:
|
|
|
|
```vue
|
|
<template>
|
|
<el-container style="height: 100vh">
|
|
<router-view />
|
|
</el-container>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
</script>
|
|
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
}
|
|
</style>
|
|
```
|
|
|
|
- [ ] **Step 8: Create web/src/router/index.ts**
|
|
|
|
Create `web/src/router/index.ts`:
|
|
|
|
```typescript
|
|
import { createRouter, createWebHistory } from 'vue-router'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
|
|
const router = createRouter({
|
|
history: createWebHistory(),
|
|
routes: [
|
|
{
|
|
path: '/login',
|
|
name: 'Login',
|
|
component: () => import('@/views/Login.vue'),
|
|
meta: { requiresAuth: false }
|
|
},
|
|
{
|
|
path: '/',
|
|
component: () => import('@/views/Layout.vue'),
|
|
meta: { requiresAuth: true },
|
|
children: [
|
|
{
|
|
path: '',
|
|
name: 'Dashboard',
|
|
component: () => import('@/views/Dashboard.vue')
|
|
},
|
|
{
|
|
path: 'servers',
|
|
name: 'Servers',
|
|
component: () => import('@/views/Servers.vue')
|
|
},
|
|
{
|
|
path: 'repos',
|
|
name: 'Repos',
|
|
component: () => import('@/views/Repos.vue')
|
|
},
|
|
{
|
|
path: 'logs',
|
|
name: 'Logs',
|
|
component: () => import('@/views/Logs.vue')
|
|
},
|
|
{
|
|
path: 'settings',
|
|
name: 'Settings',
|
|
component: () => import('@/views/Settings.vue')
|
|
}
|
|
]
|
|
}
|
|
]
|
|
})
|
|
|
|
router.beforeEach((to, _from, next) => {
|
|
const authStore = useAuthStore()
|
|
const requiresAuth = to.meta.requiresAuth !== false
|
|
|
|
if (requiresAuth && !authStore.isAuthenticated) {
|
|
next('/login')
|
|
} else if (to.path === '/login' && authStore.isAuthenticated) {
|
|
next('/')
|
|
} else {
|
|
next()
|
|
}
|
|
})
|
|
|
|
export default router
|
|
```
|
|
|
|
- [ ] **Step 9: Commit**
|
|
|
|
```bash
|
|
git add web/
|
|
git commit -m "feat: initialize Vue 3 frontend structure"
|
|
```
|
|
|
|
***
|
|
|
|
### Task 11: Create API Client and Auth Store
|
|
|
|
**Files:**
|
|
|
|
- Create: `web/src/api/index.ts`
|
|
- Create: `web/src/api/types.ts`
|
|
- Create: `web/src/stores/auth.ts`
|
|
- [ ] **Step 1: Create API types**
|
|
|
|
Create `web/src/api/types.ts`:
|
|
|
|
```typescript
|
|
export interface GiteaServer {
|
|
id: number
|
|
name: string
|
|
url: string
|
|
sync_interval: number
|
|
last_sync_at: string | null
|
|
status: string
|
|
created_at: string
|
|
}
|
|
|
|
export interface Repo {
|
|
id: number
|
|
server_id: number
|
|
name: string
|
|
full_name: string
|
|
clone_url: string
|
|
local_path: string
|
|
size: number
|
|
last_sync_at: string | null
|
|
sync_status: string
|
|
created_at: string
|
|
}
|
|
|
|
export interface SyncLog {
|
|
id: number
|
|
server_id: number
|
|
repo_id: number | null
|
|
status: string
|
|
message: string
|
|
started_at: string
|
|
finished_at: string | null
|
|
}
|
|
|
|
export interface Stats {
|
|
server_count: number
|
|
active_servers: number
|
|
repo_count: number
|
|
total_size: number
|
|
}
|
|
|
|
export interface LoginRequest {
|
|
password: string
|
|
}
|
|
|
|
export interface LoginResponse {
|
|
token: string
|
|
}
|
|
|
|
export interface CreateServerRequest {
|
|
name: string
|
|
url: string
|
|
token: string
|
|
sync_interval?: number
|
|
}
|
|
|
|
export interface UpdateServerRequest {
|
|
name?: string
|
|
url?: string
|
|
token?: string
|
|
sync_interval?: number
|
|
status?: string
|
|
}
|
|
|
|
export interface TestConnectionRequest {
|
|
url: string
|
|
token: string
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create API client**
|
|
|
|
Create `web/src/api/index.ts`:
|
|
|
|
```typescript
|
|
import axios from 'axios'
|
|
import type {
|
|
LoginRequest,
|
|
LoginResponse,
|
|
GiteaServer,
|
|
CreateServerRequest,
|
|
UpdateServerRequest,
|
|
TestConnectionRequest,
|
|
Repo,
|
|
SyncLog,
|
|
Stats
|
|
} from './types'
|
|
|
|
const api = axios.create({
|
|
baseURL: '/api',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
}
|
|
})
|
|
|
|
// Add auth token to requests
|
|
api.interceptors.request.use((config) => {
|
|
const token = localStorage.getItem('token')
|
|
if (token) {
|
|
config.headers.Authorization = `Bearer ${token}`
|
|
}
|
|
return config
|
|
})
|
|
|
|
// Handle auth errors
|
|
api.interceptors.response.use(
|
|
(response) => response,
|
|
(error) => {
|
|
if (error.response?.status === 401) {
|
|
localStorage.removeItem('token')
|
|
window.location.href = '/login'
|
|
}
|
|
return Promise.reject(error)
|
|
}
|
|
)
|
|
|
|
export const authApi = {
|
|
login: (data: LoginRequest) => api.post<LoginResponse>('/login', data),
|
|
getSettings: () => api.get('/settings'),
|
|
updateSettings: (data: any) => api.put('/settings', data)
|
|
}
|
|
|
|
export const serverApi = {
|
|
list: () => api.get<GiteaServer[]>('/servers'),
|
|
create: (data: CreateServerRequest) => api.post<GiteaServer>('/servers', data),
|
|
update: (id: number, data: UpdateServerRequest) => api.put<GiteaServer>(`/servers/${id}`, data),
|
|
delete: (id: number) => api.delete(`/servers/${id}`),
|
|
test: (data: TestConnectionRequest) => api.post('/servers/test', data),
|
|
getRepos: (id: number) => api.get<Repo[]>(`/servers/${id}/repos`),
|
|
discover: (id: number) => api.post(`/servers/${id}/discover`),
|
|
sync: (id: number) => api.post(`/servers/${id}/sync`),
|
|
getSyncStatus: (id: number) => api.get(`/servers/${id}/sync/status`)
|
|
}
|
|
|
|
export const syncApi = {
|
|
syncAll: () => api.post('/sync/all'),
|
|
getLogs: (params?: { server_id?: number; page?: number; limit?: number }) =>
|
|
api.get<{ data: SyncLog[]; page: number; limit: number; count: number }>('/sync/logs', { params }),
|
|
getStats: () => api.get<Stats>('/sync/stats')
|
|
}
|
|
|
|
export default api
|
|
```
|
|
|
|
- [ ] **Step 3: Create auth store**
|
|
|
|
Create `web/src/stores/auth.ts`:
|
|
|
|
```typescript
|
|
import { defineStore } from 'pinia'
|
|
import { ref, computed } from 'vue'
|
|
import { authApi } from '@/api'
|
|
|
|
export const useAuthStore = defineStore('auth', () => {
|
|
const token = ref<string | null>(localStorage.getItem('token'))
|
|
const loading = ref(false)
|
|
|
|
const isAuthenticated = computed(() => !!token.value)
|
|
|
|
async function login(password: string) {
|
|
loading.value = true
|
|
try {
|
|
const response = await authApi.login({ password })
|
|
token.value = response.data.token
|
|
localStorage.setItem('token', response.data.token)
|
|
return true
|
|
} catch (error) {
|
|
console.error('Login failed:', error)
|
|
return false
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function logout() {
|
|
token.value = null
|
|
localStorage.removeItem('token')
|
|
}
|
|
|
|
return {
|
|
token,
|
|
loading,
|
|
isAuthenticated,
|
|
login,
|
|
logout
|
|
}
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add web/src/api/ web/src/stores/
|
|
git commit -m "feat: add API client and auth store"
|
|
```
|
|
|
|
***
|
|
|
|
### Task 12: Create Login Page
|
|
|
|
**Files:**
|
|
|
|
- Create: `web/src/views/Login.vue`
|
|
- [ ] **Step 1: Create Login.vue**
|
|
|
|
Create `web/src/views/Login.vue`:
|
|
|
|
```vue
|
|
<template>
|
|
<div class="login-container">
|
|
<el-card class="login-card">
|
|
<template #header>
|
|
<div class="card-header">
|
|
<h1>GitM</h1>
|
|
<p>Gitea Repository Sync Tool</p>
|
|
</div>
|
|
</template>
|
|
|
|
<el-form @submit.prevent="handleLogin" label-width="0">
|
|
<el-form-item>
|
|
<el-input
|
|
v-model="password"
|
|
type="password"
|
|
placeholder="Enter password"
|
|
show-password
|
|
size="large"
|
|
@keyup.enter="handleLogin"
|
|
/>
|
|
</el-form-item>
|
|
|
|
<el-form-item>
|
|
<el-button
|
|
type="primary"
|
|
size="large"
|
|
style="width: 100%"
|
|
:loading="authStore.loading"
|
|
@click="handleLogin"
|
|
>
|
|
Login
|
|
</el-button>
|
|
</el-form-item>
|
|
</el-form>
|
|
|
|
<el-alert
|
|
v-if="error"
|
|
type="error"
|
|
:title="error"
|
|
:closable="false"
|
|
style="margin-top: 20px"
|
|
/>
|
|
</el-card>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import { ElMessage } from 'element-plus'
|
|
|
|
const router = useRouter()
|
|
const authStore = useAuthStore()
|
|
|
|
const password = ref('')
|
|
const error = ref('')
|
|
|
|
async function handleLogin() {
|
|
if (!password.value) {
|
|
error.value = 'Please enter a password'
|
|
return
|
|
}
|
|
|
|
error.value = ''
|
|
const success = await authStore.login(password.value)
|
|
|
|
if (success) {
|
|
ElMessage.success('Login successful')
|
|
router.push('/')
|
|
} else {
|
|
error.value = 'Invalid password'
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.login-container {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100vh;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
}
|
|
|
|
.login-card {
|
|
width: 400px;
|
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.card-header {
|
|
text-align: center;
|
|
}
|
|
|
|
.card-header h1 {
|
|
margin: 0;
|
|
font-size: 32px;
|
|
color: #409eff;
|
|
}
|
|
|
|
.card-header p {
|
|
margin: 5px 0 0;
|
|
color: #909399;
|
|
font-size: 14px;
|
|
}
|
|
</style>
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add web/src/views/Login.vue
|
|
git commit -m "feat: add login page"
|
|
```
|
|
|
|
***
|
|
|
|
### Task 13: Create Layout Component
|
|
|
|
**Files:**
|
|
|
|
- Create: `web/src/views/Layout.vue`
|
|
- [ ] **Step 1: Create Layout.vue**
|
|
|
|
Create `web/src/views/Layout.vue`:
|
|
|
|
```vue
|
|
<template>
|
|
<el-container>
|
|
<el-aside width="200px">
|
|
<div class="logo">
|
|
<h2>GitM</h2>
|
|
</div>
|
|
<el-menu
|
|
:default-active="activeMenu"
|
|
router
|
|
background-color="#001529"
|
|
text-color="#fff"
|
|
active-text-color="#1890ff"
|
|
>
|
|
<el-menu-item index="/">
|
|
<el-icon><Dashboard /></el-icon>
|
|
<span>Dashboard</span>
|
|
</el-menu-item>
|
|
<el-menu-item index="/servers">
|
|
<el-icon><Server /></el-icon>
|
|
<span>Servers</span>
|
|
</el-menu-item>
|
|
<el-menu-item index="/repos">
|
|
<el-icon><DocumentCopy /></el-icon>
|
|
<span>Repositories</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="/settings">
|
|
<el-icon><Setting /></el-icon>
|
|
<span>Settings</span>
|
|
</el-menu-item>
|
|
</el-menu>
|
|
</el-aside>
|
|
|
|
<el-container>
|
|
<el-header>
|
|
<div class="header-content">
|
|
<span>{{ pageTitle }}</span>
|
|
<el-button @click="handleLogout" text>
|
|
<el-icon><SwitchButton /></el-icon>
|
|
Logout
|
|
</el-button>
|
|
</div>
|
|
</el-header>
|
|
|
|
<el-main>
|
|
<router-view />
|
|
</el-main>
|
|
</el-container>
|
|
</el-container>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed } from 'vue'
|
|
import { useRouter, useRoute } from 'vue-router'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import {
|
|
Dashboard,
|
|
Server,
|
|
DocumentCopy,
|
|
Document,
|
|
Setting,
|
|
SwitchButton
|
|
} from '@element-plus/icons-vue'
|
|
|
|
const router = useRouter()
|
|
const route = useRoute()
|
|
const authStore = useAuthStore()
|
|
|
|
const activeMenu = computed(() => route.path)
|
|
|
|
const pageTitle = computed(() => {
|
|
const titles: Record<string, string> = {
|
|
'/': 'Dashboard',
|
|
'/servers': 'Gitea Servers',
|
|
'/repos': 'Repositories',
|
|
'/logs': 'Sync Logs',
|
|
'/settings': 'Settings'
|
|
}
|
|
return titles[route.path] || 'GitM'
|
|
})
|
|
|
|
function handleLogout() {
|
|
authStore.logout()
|
|
router.push('/login')
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.el-aside {
|
|
background-color: #001529;
|
|
height: 100vh;
|
|
}
|
|
|
|
.logo {
|
|
padding: 20px;
|
|
text-align: center;
|
|
border-bottom: 1px solid #1f1f1f;
|
|
}
|
|
|
|
.logo h2 {
|
|
margin: 0;
|
|
color: #fff;
|
|
}
|
|
|
|
.el-header {
|
|
background-color: #fff;
|
|
border-bottom: 1px solid #f0f0f0;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.header-content {
|
|
width: 100%;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
font-size: 18px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.el-main {
|
|
background-color: #f5f5f5;
|
|
}
|
|
</style>
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add web/src/views/Layout.vue
|
|
git commit -m "feat: add layout component with navigation"
|
|
```
|
|
|
|
***
|
|
|
|
### Task 14: Create Dashboard Page
|
|
|
|
**Files:**
|
|
|
|
- Create: `web/src/views/Dashboard.vue`
|
|
- [ ] **Step 1: Create Dashboard.vue**
|
|
|
|
Create `web/src/views/Dashboard.vue`:
|
|
|
|
```vue
|
|
<template>
|
|
<div class="dashboard">
|
|
<el-row :gutter="20">
|
|
<el-col :span="6">
|
|
<el-card shadow="hover">
|
|
<el-statistic title="Total Servers" :value="stats.server_count">
|
|
<template #prefix>
|
|
<el-icon color="#409eff"><Server /></el-icon>
|
|
</template>
|
|
</el-statistic>
|
|
</el-card>
|
|
</el-col>
|
|
<el-col :span="6">
|
|
<el-card shadow="hover">
|
|
<el-statistic title="Active Servers" :value="stats.active_servers">
|
|
<template #prefix>
|
|
<el-icon color="#67c23a"><CircleCheck /></el-icon>
|
|
</template>
|
|
</el-statistic>
|
|
</el-card>
|
|
</el-col>
|
|
<el-col :span="6">
|
|
<el-card shadow="hover">
|
|
<el-statistic title="Total Repositories" :value="stats.repo_count">
|
|
<template #prefix>
|
|
<el-icon color="#e6a23c"><DocumentCopy /></el-icon>
|
|
</template>
|
|
</el-statistic>
|
|
</el-card>
|
|
</el-col>
|
|
<el-col :span="6">
|
|
<el-card shadow="hover">
|
|
<el-statistic title="Total Size" :value="formatSize(stats.total_size)">
|
|
<template #prefix>
|
|
<el-icon color="#f56c6c"><PieChart /></el-icon>
|
|
</template>
|
|
</el-statistic>
|
|
</el-card>
|
|
</el-col>
|
|
</el-row>
|
|
|
|
<el-card style="margin-top: 20px">
|
|
<template #header>
|
|
<div class="card-header">
|
|
<span>Quick Actions</span>
|
|
</div>
|
|
</template>
|
|
<el-space>
|
|
<el-button type="primary" :loading="syncing" @click="handleSyncAll">
|
|
<el-icon><Refresh /></el-icon>
|
|
Sync All Servers
|
|
</el-button>
|
|
<el-button @click="$router.push('/servers')">
|
|
<el-icon><Plus /></el-icon>
|
|
Add Server
|
|
</el-button>
|
|
</el-space>
|
|
</el-card>
|
|
|
|
<el-card style="margin-top: 20px">
|
|
<template #header>
|
|
<div class="card-header">
|
|
<span>Recent Sync Activity</span>
|
|
<el-button text @click="$router.push('/logs')">View All</el-button>
|
|
</div>
|
|
</template>
|
|
<el-table :data="recentLogs" 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="server_id" label="Server ID" width="100" />
|
|
<el-table-column prop="status" label="Status" width="100">
|
|
<template #default="{ row }">
|
|
<el-tag :type="getStatusType(row.status)">
|
|
{{ row.status }}
|
|
</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="message" label="Message" />
|
|
</el-table>
|
|
</el-card>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted } from 'vue'
|
|
import { syncApi } from '@/api'
|
|
import { ElMessage } from 'element-plus'
|
|
import {
|
|
Server,
|
|
CircleCheck,
|
|
DocumentCopy,
|
|
PieChart,
|
|
Refresh,
|
|
Plus
|
|
} from '@element-plus/icons-vue'
|
|
import type { Stats, SyncLog } from '@/api/types'
|
|
|
|
const stats = ref<Stats>({
|
|
server_count: 0,
|
|
active_servers: 0,
|
|
repo_count: 0,
|
|
total_size: 0
|
|
})
|
|
|
|
const recentLogs = ref<SyncLog[]>([])
|
|
const syncing = ref(false)
|
|
|
|
async function loadStats() {
|
|
try {
|
|
const response = await syncApi.getStats()
|
|
stats.value = response.data
|
|
} catch (error) {
|
|
console.error('Failed to load stats:', error)
|
|
}
|
|
}
|
|
|
|
async function loadRecentLogs() {
|
|
try {
|
|
const response = await syncApi.getLogs({ limit: 10 })
|
|
recentLogs.value = response.data.data
|
|
} catch (error) {
|
|
console.error('Failed to load logs:', error)
|
|
}
|
|
}
|
|
|
|
async function handleSyncAll() {
|
|
syncing.value = true
|
|
try {
|
|
await syncApi.syncAll()
|
|
ElMessage.success('Sync started for all servers')
|
|
setTimeout(() => {
|
|
loadStats()
|
|
loadRecentLogs()
|
|
}, 1000)
|
|
} catch (error) {
|
|
ElMessage.error('Failed to start sync')
|
|
} finally {
|
|
syncing.value = false
|
|
}
|
|
}
|
|
|
|
function formatSize(bytes: number): string {
|
|
if (bytes === 0) return '0 B'
|
|
const k = 1024
|
|
const sizes = ['B', 'KB', 'MB', 'GB']
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i]
|
|
}
|
|
|
|
function formatDate(dateStr: string): string {
|
|
return new Date(dateStr).toLocaleString()
|
|
}
|
|
|
|
function getStatusType(status: string): 'success' | 'danger' | 'info' {
|
|
if (status === 'success') return 'success'
|
|
if (status === 'failed') return 'danger'
|
|
return 'info'
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadStats()
|
|
loadRecentLogs()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.dashboard {
|
|
padding: 20px;
|
|
}
|
|
|
|
.card-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
</style>
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add web/src/views/Dashboard.vue
|
|
git commit -m "feat: add dashboard page"
|
|
```
|
|
|
|
***
|
|
|
|
### Task 15: Create Servers Page
|
|
|
|
**Files:**
|
|
|
|
- Create: `web/src/views/Servers.vue`
|
|
- [ ] **Step 1: Create Servers.vue**
|
|
|
|
Create `web/src/views/Servers.vue`:
|
|
|
|
```vue
|
|
<template>
|
|
<div class="servers">
|
|
<el-card>
|
|
<template #header>
|
|
<div class="card-header">
|
|
<span>Gitea Servers</span>
|
|
<el-button type="primary" @click="showAddDialog = true">
|
|
<el-icon><Plus /></el-icon>
|
|
Add Server
|
|
</el-button>
|
|
</div>
|
|
</template>
|
|
|
|
<el-table :data="servers" stripe v-loading="loading">
|
|
<el-table-column prop="id" label="ID" width="60" />
|
|
<el-table-column prop="name" label="Name" />
|
|
<el-table-column prop="url" label="URL" />
|
|
<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="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="last_sync_at" label="Last Sync" width="180">
|
|
<template #default="{ row }">
|
|
{{ row.last_sync_at ? formatDate(row.last_sync_at) : 'Never' }}
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="Actions" width="280" fixed="right">
|
|
<template #default="{ row }">
|
|
<el-space>
|
|
<el-button size="small" @click="discoverRepos(row)">Discover</el-button>
|
|
<el-button size="small" type="primary" @click="syncServer(row)">Sync</el-button>
|
|
<el-button size="small" @click="editServer(row)">Edit</el-button>
|
|
<el-button size="small" type="danger" @click="confirmDelete(row)">Delete</el-button>
|
|
</el-space>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
</el-card>
|
|
|
|
<!-- Add/Edit Dialog -->
|
|
<el-dialog
|
|
v-model="showAddDialog"
|
|
:title="editingServer ? 'Edit Server' : 'Add Server'"
|
|
width="500px"
|
|
>
|
|
<el-form :model="serverForm" label-width="100px">
|
|
<el-form-item label="Name">
|
|
<el-input v-model="serverForm.name" placeholder="My Gitea Server" />
|
|
</el-form-item>
|
|
<el-form-item label="URL">
|
|
<el-input v-model="serverForm.url" placeholder="https://git.example.com" />
|
|
</el-form-item>
|
|
<el-form-item label="Token">
|
|
<el-input
|
|
v-model="serverForm.token"
|
|
type="password"
|
|
show-password
|
|
placeholder="Gitea API token"
|
|
/>
|
|
</el-form-item>
|
|
<el-form-item label="Sync Interval">
|
|
<el-input-number
|
|
v-model="serverForm.sync_interval"
|
|
:min="0"
|
|
:max="1440"
|
|
placeholder="0 = manual only"
|
|
/>
|
|
<span style="margin-left: 10px; color: #909399">minutes (0 = manual only)</span>
|
|
</el-form-item>
|
|
</el-form>
|
|
<el-space style="margin-top: 20px">
|
|
<el-button type="primary" @click="saveServer">Save</el-button>
|
|
<el-button @click="testConnection">Test Connection</el-button>
|
|
<el-button @click="showAddDialog = false">Cancel</el-button>
|
|
</el-space>
|
|
</el-dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted } from 'vue'
|
|
import { serverApi } from '@/api'
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
import { Plus } from '@element-plus/icons-vue'
|
|
import type { GiteaServer } from '@/api/types'
|
|
|
|
const servers = ref<GiteaServer[]>([])
|
|
const loading = ref(false)
|
|
const showAddDialog = ref(false)
|
|
const editingServer = ref<GiteaServer | null>(null)
|
|
|
|
const serverForm = ref({
|
|
name: '',
|
|
url: '',
|
|
token: '',
|
|
sync_interval: 0
|
|
})
|
|
|
|
async function loadServers() {
|
|
loading.value = true
|
|
try {
|
|
const response = await serverApi.list()
|
|
servers.value = response.data
|
|
} catch (error) {
|
|
ElMessage.error('Failed to load servers')
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function editServer(server: GiteaServer) {
|
|
editingServer.value = server
|
|
serverForm.value = {
|
|
name: server.name,
|
|
url: server.url,
|
|
token: '', // Don't show existing token
|
|
sync_interval: server.sync_interval
|
|
}
|
|
showAddDialog.value = true
|
|
}
|
|
|
|
async function saveServer() {
|
|
if (!serverForm.value.name || !serverForm.value.url || !serverForm.value.token) {
|
|
ElMessage.warning('Please fill in all required fields')
|
|
return
|
|
}
|
|
|
|
try {
|
|
if (editingServer.value) {
|
|
// Update existing
|
|
const data: any = {
|
|
name: serverForm.value.name,
|
|
url: serverForm.value.url,
|
|
sync_interval: serverForm.value.sync_interval
|
|
}
|
|
if (serverForm.value.token) {
|
|
data.token = serverForm.value.token
|
|
}
|
|
await serverApi.update(editingServer.value.id, data)
|
|
ElMessage.success('Server updated')
|
|
} else {
|
|
// Create new
|
|
await serverApi.create(serverForm.value)
|
|
ElMessage.success('Server added')
|
|
}
|
|
showAddDialog.value = false
|
|
resetForm()
|
|
loadServers()
|
|
} catch (error) {
|
|
ElMessage.error('Failed to save server')
|
|
}
|
|
}
|
|
|
|
async function testConnection() {
|
|
if (!serverForm.value.url || !serverForm.value.token) {
|
|
ElMessage.warning('Enter URL and token first')
|
|
return
|
|
}
|
|
|
|
try {
|
|
const response = await serverApi.test({
|
|
url: serverForm.value.url,
|
|
token: serverForm.value.token
|
|
})
|
|
ElMessage.success(`Connected! User: ${response.data.user}`)
|
|
} catch (error) {
|
|
ElMessage.error('Connection failed')
|
|
}
|
|
}
|
|
|
|
async function discoverRepos(server: GiteaServer) {
|
|
try {
|
|
const response = await serverApi.discover(server.id)
|
|
ElMessage.success(response.data.message)
|
|
} catch (error) {
|
|
ElMessage.error('Discovery failed')
|
|
}
|
|
}
|
|
|
|
async function syncServer(server: GiteaServer) {
|
|
try {
|
|
await serverApi.sync(server.id)
|
|
ElMessage.success('Sync started')
|
|
} catch (error: any) {
|
|
if (error.response?.status === 409) {
|
|
ElMessage.warning('Sync already in progress')
|
|
} else {
|
|
ElMessage.error('Failed to start sync')
|
|
}
|
|
}
|
|
}
|
|
|
|
function confirmDelete(server: GiteaServer) {
|
|
ElMessageBox.confirm(
|
|
'This will delete the server and all its repositories. Continue?',
|
|
'Confirm Delete',
|
|
{
|
|
confirmButtonText: 'Delete',
|
|
cancelButtonText: 'Cancel',
|
|
type: 'warning'
|
|
}
|
|
).then(async () => {
|
|
try {
|
|
await serverApi.delete(server.id)
|
|
ElMessage.success('Server deleted')
|
|
loadServers()
|
|
} catch (error) {
|
|
ElMessage.error('Failed to delete server')
|
|
}
|
|
})
|
|
}
|
|
|
|
function resetForm() {
|
|
editingServer.value = null
|
|
serverForm.value = {
|
|
name: '',
|
|
url: '',
|
|
token: '',
|
|
sync_interval: 0
|
|
}
|
|
}
|
|
|
|
function formatDate(dateStr: string): string {
|
|
return new Date(dateStr).toLocaleString()
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadServers()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.servers {
|
|
padding: 20px;
|
|
}
|
|
|
|
.card-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
</style>
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add web/src/views/Servers.vue
|
|
git commit -m "feat: add servers management page"
|
|
```
|
|
|
|
***
|
|
|
|
### Task 16: Create Repositories, Logs, and Settings Pages
|
|
|
|
**Files:**
|
|
|
|
- Create: `web/src/views/Repos.vue`
|
|
- Create: `web/src/views/Logs.vue`
|
|
- Create: `web/src/views/Settings.vue`
|
|
- [ ] **Step 1: Create Repos.vue**
|
|
|
|
Create `web/src/views/Repos.vue`:
|
|
|
|
```vue
|
|
<template>
|
|
<div class="repos">
|
|
<el-card>
|
|
<template #header>
|
|
<div class="card-header">
|
|
<span>Repositories</span>
|
|
<el-select v-model="selectedServer" placeholder="Select Server" style="width: 200px">
|
|
<el-option label="All Servers" :value="0" />
|
|
<el-option
|
|
v-for="server in servers"
|
|
:key="server.id"
|
|
:label="server.name"
|
|
:value="server.id"
|
|
/>
|
|
</el-select>
|
|
</div>
|
|
</template>
|
|
|
|
<el-table :data="repos" stripe v-loading="loading">
|
|
<el-table-column prop="full_name" label="Repository" />
|
|
<el-table-column prop="server_id" label="Server ID" width="100" />
|
|
<el-table-column prop="size" label="Size" width="120">
|
|
<template #default="{ row }">
|
|
{{ formatSize(row.size) }}
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="sync_status" label="Sync Status" width="120">
|
|
<template #default="{ row }">
|
|
<el-tag :type="getStatusType(row.sync_status)">
|
|
{{ 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 ? formatDate(row.last_sync_at) : 'Never' }}
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
|
|
<el-pagination
|
|
v-if="repos.length > 0"
|
|
style="margin-top: 20px; justify-content: center"
|
|
layout="prev, pager, next"
|
|
:page-size="50"
|
|
:total="totalCount"
|
|
@current-change="loadRepos"
|
|
/>
|
|
</el-card>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, watch, onMounted } from 'vue'
|
|
import { serverApi } from '@/api'
|
|
import type { GiteaServer, Repo } from '@/api/types'
|
|
|
|
const servers = ref<GiteaServer[]>([])
|
|
const repos = ref<Repo[]>([])
|
|
const loading = ref(false)
|
|
const selectedServer = ref(0)
|
|
const totalCount = ref(0)
|
|
|
|
async function loadServers() {
|
|
try {
|
|
const response = await serverApi.list()
|
|
servers.value = response.data
|
|
} catch (error) {
|
|
console.error('Failed to load servers:', error)
|
|
}
|
|
}
|
|
|
|
async function loadRepos(page = 1) {
|
|
loading.value = true
|
|
try {
|
|
if (selectedServer.value === 0) {
|
|
// Load repos from all servers
|
|
const allRepos: Repo[] = []
|
|
for (const server of servers.value) {
|
|
const response = await serverApi.getRepos(server.id)
|
|
allRepos.push(...response.data)
|
|
}
|
|
repos.value = allRepos
|
|
totalCount.value = allRepos.length
|
|
} else {
|
|
const response = await serverApi.getRepos(selectedServer.value)
|
|
repos.value = response.data
|
|
totalCount.value = response.data.length
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load repos:', error)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function formatSize(bytes: number): string {
|
|
if (bytes === 0) return '0 B'
|
|
const k = 1024
|
|
const sizes = ['B', 'KB', 'MB', 'GB']
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i]
|
|
}
|
|
|
|
function formatDate(dateStr: string): string {
|
|
return new Date(dateStr).toLocaleString()
|
|
}
|
|
|
|
function getStatusType(status: string): 'success' | 'danger' | 'warning' | 'info' {
|
|
if (status === 'success') return 'success'
|
|
if (status === 'failed') return 'danger'
|
|
if (status === 'syncing') return 'warning'
|
|
return 'info'
|
|
}
|
|
|
|
watch(selectedServer, () => {
|
|
loadRepos()
|
|
})
|
|
|
|
onMounted(() => {
|
|
loadServers()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.repos {
|
|
padding: 20px;
|
|
}
|
|
|
|
.card-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
</style>
|
|
```
|
|
|
|
- [ ] **Step 2: Create Logs.vue**
|
|
|
|
Create `web/src/views/Logs.vue`:
|
|
|
|
```vue
|
|
<template>
|
|
<div class="logs">
|
|
<el-card>
|
|
<template #header>
|
|
<div class="card-header">
|
|
<span>Sync Logs</span>
|
|
<el-select v-model="filterServer" placeholder="Filter by Server" clearable style="width: 200px">
|
|
<el-option
|
|
v-for="server in servers"
|
|
:key="server.id"
|
|
:label="server.name"
|
|
:value="server.id"
|
|
/>
|
|
</el-select>
|
|
</div>
|
|
</template>
|
|
|
|
<el-table :data="logs" stripe v-loading="loading">
|
|
<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="server_id" label="Server ID" width="100" />
|
|
<el-table-column prop="status" label="Status" width="100">
|
|
<template #default="{ row }">
|
|
<el-tag :type="getStatusType(row.status)">
|
|
{{ row.status }}
|
|
</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="message" label="Message" />
|
|
<el-table-column prop="finished_at" label="Duration" width="100">
|
|
<template #default="{ row }">
|
|
{{ getDuration(row) }}
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
|
|
<el-pagination
|
|
style="margin-top: 20px; justify-content: center"
|
|
layout="prev, pager, next"
|
|
:page-size="pageSize"
|
|
:current-page="currentPage"
|
|
:total="totalCount"
|
|
@current-change="loadLogs"
|
|
/>
|
|
</el-card>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, watch, onMounted } from 'vue'
|
|
import { serverApi, syncApi } from '@/api'
|
|
import type { GiteaServer, SyncLog } from '@/api/types'
|
|
|
|
const servers = ref<GiteaServer[]>([])
|
|
const logs = ref<SyncLog[]>([])
|
|
const loading = ref(false)
|
|
const filterServer = ref<number | null>(null)
|
|
const currentPage = ref(1)
|
|
const pageSize = 50
|
|
const totalCount = ref(0)
|
|
|
|
async function loadServers() {
|
|
try {
|
|
const response = await serverApi.list()
|
|
servers.value = response.data
|
|
} catch (error) {
|
|
console.error('Failed to load servers:', error)
|
|
}
|
|
}
|
|
|
|
async function loadLogs(page = 1) {
|
|
loading.value = true
|
|
currentPage.value = page
|
|
try {
|
|
const params: any = { page, limit: pageSize }
|
|
if (filterServer.value) {
|
|
params.server_id = filterServer.value
|
|
}
|
|
const response = await syncApi.getLogs(params)
|
|
logs.value = response.data.data
|
|
totalCount.value = response.data.count
|
|
} catch (error) {
|
|
console.error('Failed to load logs:', error)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function formatDate(dateStr: string): string {
|
|
return new Date(dateStr).toLocaleString()
|
|
}
|
|
|
|
function getStatusType(status: string): 'success' | 'danger' | 'info' {
|
|
if (status === 'success') return 'success'
|
|
if (status === 'failed') return 'danger'
|
|
return 'info'
|
|
}
|
|
|
|
function getDuration(log: SyncLog): string {
|
|
if (!log.finished_at) return '-'
|
|
const start = new Date(log.started_at).getTime()
|
|
const end = new Date(log.finished_at).getTime()
|
|
const diff = (end - start) / 1000
|
|
if (diff < 60) return `${diff}s`
|
|
return `${Math.floor(diff / 60)}m ${diff % 60}s`
|
|
}
|
|
|
|
watch(filterServer, () => {
|
|
loadLogs(1)
|
|
})
|
|
|
|
onMounted(() => {
|
|
loadServers()
|
|
loadLogs()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.logs {
|
|
padding: 20px;
|
|
}
|
|
|
|
.card-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
</style>
|
|
```
|
|
|
|
- [ ] **Step 3: Create Settings.vue**
|
|
|
|
Create `web/src/views/Settings.vue`:
|
|
|
|
```vue
|
|
<template>
|
|
<div class="settings">
|
|
<el-card>
|
|
<template #header>
|
|
<span>Settings</span>
|
|
</template>
|
|
|
|
<el-form :model="settings" label-width="150px" style="max-width: 600px">
|
|
<el-form-item label="Current Password">
|
|
<el-input v-model="adminPassword" type="password" show-password disabled />
|
|
<span style="margin-left: 10px; color: #909399; font-size: 12px">
|
|
Hidden for security
|
|
</span>
|
|
</el-form-item>
|
|
|
|
<el-form-item label="New Password">
|
|
<el-input
|
|
v-model="newPassword"
|
|
type="password"
|
|
show-password
|
|
placeholder="Leave empty to keep current"
|
|
/>
|
|
</el-form-item>
|
|
|
|
<el-form-item label="Listen Address">
|
|
<el-input v-model="settings.listen_addr" placeholder=":9000" />
|
|
</el-form-item>
|
|
|
|
<el-form-item label="Repos Directory">
|
|
<el-input v-model="settings.repos_dir" placeholder="./data/repos" />
|
|
</el-form-item>
|
|
|
|
<el-form-item label="Max Concurrent Sync">
|
|
<el-input-number v-model="settings.max_concurrent" :min="1" :max="10" />
|
|
</el-form-item>
|
|
|
|
<el-form-item>
|
|
<el-space>
|
|
<el-button type="primary" @click="saveSettings">Save Settings</el-button>
|
|
<el-button @click="loadSettings">Reset</el-button>
|
|
</el-space>
|
|
</el-form-item>
|
|
|
|
<el-alert
|
|
type="info"
|
|
title="Note: Listen address change requires restarting the application"
|
|
:closable="false"
|
|
style="margin-top: 20px"
|
|
/>
|
|
</el-form>
|
|
</el-card>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted } from 'vue'
|
|
import { authApi } from '@/api'
|
|
import { ElMessage } from 'element-plus'
|
|
|
|
const settings = ref({
|
|
listen_addr: ':9000',
|
|
repos_dir: '',
|
|
max_concurrent: 3
|
|
})
|
|
|
|
const adminPassword = ref('********')
|
|
const newPassword = ref('')
|
|
|
|
async function loadSettings() {
|
|
try {
|
|
const response = await authApi.getSettings()
|
|
settings.value = {
|
|
listen_addr: response.data.listen_addr,
|
|
repos_dir: response.data.repos_dir,
|
|
max_concurrent: response.data.max_concurrent
|
|
}
|
|
} catch (error) {
|
|
ElMessage.error('Failed to load settings')
|
|
}
|
|
}
|
|
|
|
async function saveSettings() {
|
|
try {
|
|
const data: any = {
|
|
listen_addr: settings.value.listen_addr,
|
|
repos_dir: settings.value.repos_dir,
|
|
max_concurrent: settings.value.max_concurrent
|
|
}
|
|
|
|
if (newPassword.value) {
|
|
data.admin_password = newPassword.value
|
|
}
|
|
|
|
await authApi.updateSettings(data)
|
|
ElMessage.success('Settings saved')
|
|
newPassword.value = ''
|
|
} catch (error) {
|
|
ElMessage.error('Failed to save settings')
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadSettings()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.settings {
|
|
padding: 20px;
|
|
}
|
|
</style>
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add web/src/views/Repos.vue web/src/views/Logs.vue web/src/views/Settings.vue
|
|
git commit -m "feat: add repos, logs, and settings pages"
|
|
```
|
|
|
|
***
|
|
|
|
## Phase 5: Frontend Build Integration
|
|
|
|
### Task 17: Embed Frontend in Go Binary
|
|
|
|
**Files:**
|
|
|
|
- Modify: `main.go`
|
|
- Modify: `Makefile`
|
|
- [ ] **Step 1: Add embed directive to main.go**
|
|
|
|
Edit `main.go`, add after the imports:
|
|
|
|
```go
|
|
//go:embed web/dist
|
|
var webFS embed.FS
|
|
```
|
|
|
|
And add `"embed"` and `"io/fs"` to imports:
|
|
|
|
```go
|
|
import (
|
|
"embed"
|
|
"flag"
|
|
"fmt"
|
|
"io/fs"
|
|
"log"
|
|
"os"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gitm/internal/config"
|
|
"gitm/internal/database"
|
|
"gitm/internal/handler"
|
|
"gitm/internal/middleware"
|
|
"gitm/internal/sync"
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 2: Serve embedded frontend**
|
|
|
|
Edit `main.go`, update the `runServer` function. Find the comment about static files and replace with:
|
|
|
|
```go
|
|
// Serve embedded frontend
|
|
distFS, err := fs.Sub(webFS, "web/dist")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create sub filesystem: %w", err)
|
|
}
|
|
r.StaticFS("/", http.FS(distFS))
|
|
// API routes fallback for SPA
|
|
r.NoRoute(func(c *gin.Context) {
|
|
c.FileFromFS("/", http.FS(distFS))
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 3: Add** **`"net/http"`** **to imports**
|
|
|
|
Edit `main.go` imports:
|
|
|
|
```go
|
|
import (
|
|
"embed"
|
|
"flag"
|
|
"fmt"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gitm/internal/config"
|
|
"gitm/internal/database"
|
|
"gitm/internal/handler"
|
|
"gitm/internal/middleware"
|
|
"gitm/internal/sync"
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 4: Update runServer function**
|
|
|
|
Edit `main.go`, update `runServer` to serve static files correctly:
|
|
|
|
```go
|
|
func runServer(cfg *config.Config, engine *sync.Engine, scheduler *sync.Scheduler) error {
|
|
gin.SetMode(gin.ReleaseMode)
|
|
r := gin.Default()
|
|
|
|
// Serve embedded frontend
|
|
distFS, err := fs.Sub(webFS, "web/dist")
|
|
if err == nil {
|
|
// Try to serve embedded files first
|
|
r.StaticFS("/assets", http.FS(distFS))
|
|
r.NoRoute(func(c *gin.Context) {
|
|
c.Request.URL.Path = "/"
|
|
c.FileFromFS("/", http.FS(distFS))
|
|
})
|
|
} else {
|
|
// Fallback to local files for development
|
|
r.Static("/", "./web/dist")
|
|
r.NoRoute(func(c *gin.Context) {
|
|
c.File("./web/dist/index.html")
|
|
})
|
|
}
|
|
|
|
// API routes
|
|
api := r.Group("/api")
|
|
{
|
|
// Public routes
|
|
api.POST("/login", handler.HandleLogin)
|
|
api.POST("/init", handler.HandleInit)
|
|
|
|
// Protected routes
|
|
protected := api.Group("")
|
|
protected.Use(middleware.AuthMiddleware())
|
|
{
|
|
// Settings
|
|
protected.GET("/settings", handler.HandleGetSettings)
|
|
protected.PUT("/settings", handler.HandleUpdateSettings)
|
|
|
|
// Servers
|
|
protected.GET("/servers", handler.HandleListServers)
|
|
protected.POST("/servers", handler.HandleCreateServer)
|
|
protected.PUT("/servers/:id", handler.HandleUpdateServer)
|
|
protected.DELETE("/servers/:id", handler.HandleDeleteServer)
|
|
protected.POST("/servers/:id/test", handler.HandleTestConnection)
|
|
protected.GET("/servers/:id/repos", handler.HandleListRepos)
|
|
protected.POST("/servers/:id/discover", handler.HandleDiscoverRepos)
|
|
protected.POST("/servers/:id/sync", handler.HandleSyncServer(engine))
|
|
protected.GET("/servers/:id/sync/status", handler.HandleGetSyncStatus(engine))
|
|
|
|
// Sync
|
|
protected.POST("/sync/all", handler.HandleSyncAll(engine))
|
|
|
|
// Logs and stats
|
|
protected.GET("/sync/logs", handler.HandleGetSyncLogs)
|
|
protected.GET("/sync/stats", handler.HandleGetStats)
|
|
}
|
|
}
|
|
|
|
return r.Run(cfg.ListenAddr)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Update Makefile**
|
|
|
|
Edit `Makefile` to fix build order:
|
|
|
|
```makefile
|
|
.PHONY: all build frontend clean test run
|
|
|
|
# Build frontend first, then build the binary
|
|
all: frontend build
|
|
|
|
# Build Vue frontend
|
|
frontend:
|
|
cd web && npm install && npm run build
|
|
|
|
# Build Go binary (requires frontend built)
|
|
build:
|
|
go build -o bin/gitm .
|
|
|
|
# Clean build artifacts
|
|
clean:
|
|
rm -rf bin/
|
|
rm -rf web/dist/
|
|
rm -rf web/node_modules/
|
|
|
|
# Run tests
|
|
test:
|
|
go test -v ./...
|
|
|
|
# Run the application (development mode with local frontend)
|
|
run-dev:
|
|
cd web && npm run dev
|
|
|
|
# Run the built binary
|
|
run:
|
|
./bin/gitm
|
|
|
|
# Cross-compile for Linux
|
|
build-linux:
|
|
GOOS=linux GOARCH=amd64 go build -o bin/gitm-linux .
|
|
|
|
# Cross-compile for Windows
|
|
build-windows:
|
|
GOOS=windows GOARCH=amd64 go build -o bin/gitm.exe .
|
|
```
|
|
|
|
- [ ] **Step 6: Test build**
|
|
|
|
```bash
|
|
cd web && npm install && npm run build
|
|
cd ..
|
|
go build -o bin/gitm .
|
|
```
|
|
|
|
Expected: Success, `bin/gitm` created
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add main.go Makefile
|
|
git commit -m "feat: embed frontend in Go binary"
|
|
```
|
|
|
|
***
|
|
|
|
## Phase 6: Testing and Documentation
|
|
|
|
### Task 18: Create Tests
|
|
|
|
**Files:**
|
|
|
|
- Create: `internal/database/database_test.go`
|
|
- Create: `internal/gitea/client_test.go`
|
|
- [ ] **Step 1: Create database tests**
|
|
|
|
Create `internal/database/database_test.go`:
|
|
|
|
```go
|
|
package database
|
|
|
|
import (
|
|
"os"
|
|
"testing"
|
|
)
|
|
|
|
func TestDatabase(t *testing.T) {
|
|
// Use temp database
|
|
tmpDB := "/tmp/test_gitm.db"
|
|
defer os.Remove(tmpDB)
|
|
|
|
if err := Initialize(tmpDB); err != nil {
|
|
t.Fatalf("Failed to initialize database: %v", err)
|
|
}
|
|
defer Close()
|
|
|
|
// Test settings
|
|
t.Run("Settings", func(t *testing.T) {
|
|
err := SetSetting("test_key", "test_value")
|
|
if err != nil {
|
|
t.Errorf("Failed to set setting: %v", err)
|
|
}
|
|
|
|
value, err := GetSetting("test_key")
|
|
if err != nil {
|
|
t.Errorf("Failed to get setting: %v", err)
|
|
}
|
|
if value != "test_value" {
|
|
t.Errorf("Expected test_value, got %s", value)
|
|
}
|
|
})
|
|
|
|
// Test server CRUD
|
|
t.Run("ServerCRUD", func(t *testing.T) {
|
|
server := &GiteaServer{
|
|
Name: "Test Server",
|
|
URL: "https://test.com",
|
|
Token: "test-token",
|
|
SyncInterval: 60,
|
|
Status: "active",
|
|
}
|
|
|
|
// Create
|
|
if err := CreateServer(server); err != nil {
|
|
t.Errorf("Failed to create server: %v", err)
|
|
}
|
|
if server.ID == 0 {
|
|
t.Error("Expected server ID to be set")
|
|
}
|
|
|
|
// Read
|
|
fetched, err := GetServer(server.ID)
|
|
if err != nil {
|
|
t.Errorf("Failed to get server: %v", err)
|
|
}
|
|
if fetched.Name != server.Name {
|
|
t.Errorf("Expected %s, got %s", server.Name, fetched.Name)
|
|
}
|
|
|
|
// Update
|
|
fetched.Name = "Updated Server"
|
|
if err := UpdateServer(fetched); err != nil {
|
|
t.Errorf("Failed to update server: %v", err)
|
|
}
|
|
|
|
// List
|
|
servers, err := GetServers()
|
|
if err != nil {
|
|
t.Errorf("Failed to list servers: %v", err)
|
|
}
|
|
if len(servers) != 1 {
|
|
t.Errorf("Expected 1 server, got %d", len(servers))
|
|
}
|
|
|
|
// Delete
|
|
if err := DeleteServer(server.ID); err != nil {
|
|
t.Errorf("Failed to delete server: %v", err)
|
|
}
|
|
})
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests**
|
|
|
|
```bash
|
|
go test ./internal/database/... -v
|
|
```
|
|
|
|
Expected: All tests pass
|
|
|
|
- [ ] **Step 3: Create Gitea client tests**
|
|
|
|
Create `internal/gitea/client_test.go`:
|
|
|
|
```go
|
|
package gitea
|
|
|
|
import (
|
|
"testing"
|
|
)
|
|
|
|
func TestNewClient(t *testing.T) {
|
|
client, err := NewClient("https://gitea.com", "")
|
|
if err != nil {
|
|
t.Errorf("Failed to create client: %v", err)
|
|
}
|
|
if client == nil {
|
|
t.Error("Expected client to be created")
|
|
}
|
|
}
|
|
|
|
func TestNewClientInvalidURL(t *testing.T) {
|
|
_, err := NewClient("://invalid", "")
|
|
if err == nil {
|
|
t.Error("Expected error for invalid URL")
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests**
|
|
|
|
```bash
|
|
go test ./internal/gitea/... -v
|
|
```
|
|
|
|
Expected: All tests pass
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add internal/database/database_test.go internal/gitea/client_test.go
|
|
git commit -m "test: add unit tests for database and Gitea client"
|
|
```
|
|
|
|
***
|
|
|
|
### Task 19: Final Documentation
|
|
|
|
**Files:**
|
|
|
|
- Modify: `README.md`
|
|
- Create: `.gitignore`
|
|
- [ ] **Step 1: Update README.md**
|
|
|
|
Replace `README.md` with:
|
|
|
|
````markdown
|
|
# GitM - Gitea Repository Sync Tool
|
|
|
|
Cross-platform tool to synchronize all repositories from multiple Gitea servers to local storage.
|
|
|
|
## Features
|
|
|
|
- **Single Binary**: One executable file contains everything
|
|
- **Web UI**: Vue 3 + Element Plus management interface
|
|
- **SQLite Storage**: Lightweight, portable database
|
|
- **JWT Authentication**: Secure token-based auth
|
|
- **Scheduled Sync**: Automatic sync with configurable intervals
|
|
- **Manual Sync**: On-demand sync for any server
|
|
- **Cross-Platform**: Runs on Windows and Linux
|
|
- **Mirror Clone**: Uses `git clone --mirror` for complete backup
|
|
|
|
## Quick Start
|
|
|
|
### Build
|
|
|
|
```bash
|
|
# Clone the repository
|
|
git clone <repository-url>
|
|
cd gitm
|
|
|
|
# Build everything (frontend + backend)
|
|
make all
|
|
|
|
# The binary will be at bin/gitm
|
|
````
|
|
|
|
### First Run
|
|
|
|
```bash
|
|
# Initialize and set admin password
|
|
./bin/gitm --init
|
|
|
|
# Start the server (default port 9000)
|
|
./bin/gitm
|
|
|
|
# Or specify custom port
|
|
./bin/gitm --addr :9090
|
|
```
|
|
|
|
### Access Web UI
|
|
|
|
Open your browser and navigate to:
|
|
|
|
- <http://localhost:9000> (default)
|
|
- <http://localhost:9090> (if you used --addr :9090)
|
|
|
|
## Usage
|
|
|
|
### Adding a Gitea Server
|
|
|
|
1. Log in with the password you set during initialization
|
|
2. Go to **Servers** page
|
|
3. Click **Add Server**
|
|
4. Fill in:
|
|
- **Name**: A friendly name for this server
|
|
- **URL**: The Gitea server URL (e.g., `https://git.example.com`)
|
|
- **Token**: Your Gitea API access token
|
|
- **Sync Interval**: Minutes between automatic syncs (0 = manual only)
|
|
5. Click **Test Connection** to verify
|
|
6. Click **Save**
|
|
|
|
### Creating a Gitea API Token
|
|
|
|
1. Log into your Gitea instance
|
|
2. Go to **Settings** → **Applications**
|
|
3. Click **Generate New Token**
|
|
4. Give it a name and select **read** permission for repositories
|
|
5. Copy the token (you won't see it again!)
|
|
|
|
### Syncing Repositories
|
|
|
|
There are two ways to sync:
|
|
|
|
1. **Automatic**: Set a sync interval (in minutes) when adding a server
|
|
2. **Manual**: Click the **Sync** button on the Servers page
|
|
|
|
To discover new repositories without syncing, click **Discover**.
|
|
|
|
### Storage
|
|
|
|
Repositories are stored as bare git mirrors at:
|
|
|
|
```
|
|
<data_dir>/repos/server_<id>_<name>/<owner>/<repo>.git
|
|
```
|
|
|
|
Default data directory is `./data` relative to the binary.
|
|
|
|
## Configuration
|
|
|
|
Settings are stored in the SQLite database and can be changed via the Web UI:
|
|
|
|
- **Admin Password**: Change your login password
|
|
- **Listen Address**: Port to listen on (requires restart)
|
|
- **Repos Directory**: Where to store cloned repositories
|
|
- **Max Concurrent Sync**: Maximum parallel sync operations
|
|
|
|
## API Endpoints
|
|
|
|
The API is available at `/api/` and requires JWT authentication:
|
|
|
|
### Authentication
|
|
|
|
- `POST /api/login` - Authenticate and get token
|
|
- `POST /api/init` - Initialize admin password
|
|
|
|
### Settings
|
|
|
|
- `GET /api/settings` - Get all settings
|
|
- `PUT /api/settings` - Update settings
|
|
|
|
### Servers
|
|
|
|
- `GET /api/servers` - List all servers
|
|
- `POST /api/servers` - Add a server
|
|
- `PUT /api/servers/:id` - Update a server
|
|
- `DELETE /api/servers/:id` - Delete a server
|
|
- `POST /api/servers/:id/test` - Test connection
|
|
- `GET /api/servers/:id/repos` - List repositories
|
|
- `POST /api/servers/:id/discover` - Discover repositories
|
|
- `POST /api/servers/:id/sync` - Trigger sync
|
|
- `GET /api/servers/:id/sync/status` - Get sync status
|
|
|
|
### Sync
|
|
|
|
- `POST /api/sync/all` - Sync all servers
|
|
- `GET /api/sync/logs` - Get sync logs
|
|
- `GET /api/sync/stats` - Get statistics
|
|
|
|
## Development
|
|
|
|
### Prerequisites
|
|
|
|
- Go 1.26+
|
|
- Node.js 18+
|
|
- npm
|
|
|
|
### Frontend Development
|
|
|
|
```bash
|
|
cd web
|
|
npm install
|
|
npm run dev
|
|
```
|
|
|
|
The dev server runs on <http://localhost:5173> with API proxy to :9000.
|
|
|
|
### Backend Development
|
|
|
|
```bash
|
|
go run main.go
|
|
```
|
|
|
|
### Running Tests
|
|
|
|
```bash
|
|
go test ./...
|
|
```
|
|
|
|
### Project Structure
|
|
|
|
```
|
|
gitm/
|
|
├── main.go # Entry point
|
|
├── internal/
|
|
│ ├── config/ # Configuration
|
|
│ ├── database/ # SQLite database
|
|
│ ├── models/ # Data models
|
|
│ ├── gitea/ # Gitea API client
|
|
│ ├── sync/ # Sync engine
|
|
│ ├── middleware/ # JWT middleware
|
|
│ └── handler/ # HTTP handlers
|
|
├── web/ # Vue 3 frontend
|
|
│ ├── src/
|
|
│ │ ├── api/ # API client
|
|
│ │ ├── stores/ # Pinia stores
|
|
│ │ ├── views/ # Page components
|
|
│ │ └── router/ # Vue Router
|
|
│ └── dist/ # Build output
|
|
├── Makefile # Build commands
|
|
└── docs/ # Documentation
|
|
```
|
|
|
|
## Security Notes
|
|
|
|
- Gitea API tokens are stored encrypted in the database
|
|
- JWT tokens expire after 24 hours
|
|
- By default, the server listens on localhost only
|
|
- Change the default JWT secret in production
|
|
|
|
## License
|
|
|
|
MIT License
|
|
|
|
```
|
|
|
|
- [ ] **Step 2: Create .gitignore**
|
|
|
|
Create `.gitignore`:
|
|
|
|
```
|
|
|
|
# Binaries
|
|
|
|
bin/
|
|
gitm
|
|
gitm.exe
|
|
gitm-linux
|
|
|
|
# Data
|
|
|
|
data/
|
|
\*.db
|
|
\*.db-shm
|
|
\*.db-wal
|
|
|
|
# Go
|
|
|
|
\*.exe
|
|
\*.exe\~
|
|
\*.dll
|
|
\*.so
|
|
\*.dylib
|
|
\*.test
|
|
\*.out
|
|
go.work
|
|
|
|
# Node
|
|
|
|
web/node\_modules/
|
|
web/dist/
|
|
|
|
# IDE
|
|
|
|
.idea/
|
|
.vscode/
|
|
\*.swp
|
|
\*.swo
|
|
\*\~
|
|
|
|
# OS
|
|
|
|
.DS\_Store
|
|
Thumbs.db
|
|
|
|
# Temporary
|
|
|
|
\*.log
|
|
\*.tmp
|
|
|
|
````
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add README.md .gitignore
|
|
git commit -m "docs: update README and add .gitignore"
|
|
````
|
|
|
|
***
|
|
|
|
## Task 20: Final Build Verification
|
|
|
|
**Files:**
|
|
|
|
- None (verification task)
|
|
- [ ] **Step 1: Full clean build**
|
|
|
|
```bash
|
|
make clean
|
|
make all
|
|
```
|
|
|
|
Expected: Clean build completes successfully
|
|
|
|
- [ ] **Step 2: Verify binary works**
|
|
|
|
```bash
|
|
./bin/gitm --help
|
|
```
|
|
|
|
Expected: Shows usage or starts server
|
|
|
|
- [ ] **Step 3: Test initialization**
|
|
|
|
```bash
|
|
rm -rf data/
|
|
./bin/gitm --init
|
|
# Enter: testpassword123
|
|
```
|
|
|
|
Expected: "Initialized successfully!"
|
|
|
|
- [ ] **Step 4: Quick functional test**
|
|
|
|
```bash
|
|
# Start server in background
|
|
./bin/gitm &
|
|
SERVER_PID=$!
|
|
|
|
# Wait for startup
|
|
sleep 2
|
|
|
|
# Test API (login)
|
|
curl -X POST http://localhost:9000/api/login \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"password":"testpassword123"}'
|
|
|
|
# Kill server
|
|
kill $SERVER_PID
|
|
```
|
|
|
|
Expected: Returns `{"token":"..."}`
|
|
|
|
- [ ] **Step 5: Cross-compile test**
|
|
|
|
```bash
|
|
make build-linux
|
|
make build-windows
|
|
ls -lh bin/
|
|
```
|
|
|
|
Expected: `gitm-linux` and `gitm.exe` created
|
|
|
|
- [ ] **Step 6: Final commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "chore: final build verification complete"
|
|
```
|
|
|
|
***
|
|
|
|
## Implementation Complete
|
|
|
|
The GitM project is now fully implemented with:
|
|
|
|
1. ✅ Go backend with Gin framework
|
|
2. ✅ SQLite database with all required tables
|
|
3. ✅ JWT authentication
|
|
4. ✅ Gitea API client for repo discovery
|
|
5. ✅ Sync engine with git clone/fetch operations
|
|
6. ✅ Scheduled sync with configurable intervals
|
|
7. ✅ Vue 3 + Element Plus frontend
|
|
8. ✅ Embedded frontend in single binary
|
|
9. ✅ Cross-platform build support
|
|
10. ✅ Complete API coverage
|
|
11. ✅ Unit tests
|
|
12. ✅ Documentation
|
|
|
|
### Next Steps (Optional Enhancements):
|
|
|
|
1. Add systemd service file for Linux
|
|
2. Add Windows service wrapper
|
|
3. Implement token encryption at rest
|
|
4. Add webhook notifications
|
|
5. Implement repo pruning (remove deleted repos)
|
|
6. Add more detailed sync progress reporting
|
|
7. Implement backup/restore functionality
|
|
8. Add Docker support
|
|
|