Compare commits
10 Commits
6e935a6a41
...
34944518f0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34944518f0 | ||
|
|
a7cfa381e7 | ||
|
|
6e8cbb38d4 | ||
|
|
932b367806 | ||
|
|
373cb70633 | ||
|
|
333e999d76 | ||
|
|
56deffc6a4 | ||
|
|
86b21ccc31 | ||
|
|
e55c892488 | ||
|
|
872bab34b6 |
8
.claude/settings.local.json
Normal file
8
.claude/settings.local.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(mkdir:*)",
|
||||
"Bash(rm:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -1,6 +1,12 @@
|
||||
bin/
|
||||
data/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
*.exe
|
||||
*.test
|
||||
*.out
|
||||
web/dist/
|
||||
web/node_modules/
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
31
README.md
31
README.md
@@ -4,7 +4,7 @@ Cross-platform tool to synchronize all repositories from multiple Gitea servers
|
||||
|
||||
## Features
|
||||
|
||||
- Single binary deployment
|
||||
- Single binary deployment (frontend embedded)
|
||||
- Web UI for management (Vue 3 + Element Plus)
|
||||
- SQLite database
|
||||
- JWT authentication
|
||||
@@ -14,20 +14,47 @@ Cross-platform tool to synchronize all repositories from multiple Gitea servers
|
||||
## Build
|
||||
|
||||
```bash
|
||||
make all
|
||||
# Build binary with embedded frontend
|
||||
go build -o bin/gitm .
|
||||
|
||||
# Or with CGO for SQLite support
|
||||
CGO_ENABLED=1 go build -o bin/gitm .
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
# First-time initialization (set admin password)
|
||||
./bin/gitm --init
|
||||
|
||||
# Start server (default :9000)
|
||||
./bin/gitm
|
||||
|
||||
# Custom address
|
||||
./bin/gitm --addr :9090
|
||||
|
||||
# Custom data directory
|
||||
./bin/gitm --data /path/to/data
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
1. Initialize: `./bin/gitm --init` -- sets admin password
|
||||
2. Start: `./bin/gitm` -- starts web server
|
||||
3. Open browser to `http://localhost:9000`
|
||||
4. Login and add Gitea servers
|
||||
5. Discover repos and sync
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Frontend dev server
|
||||
cd web && npm install && npm run dev
|
||||
|
||||
# Backend
|
||||
go run main.go
|
||||
|
||||
# Run tests
|
||||
go test ./internal/gitea/... -v
|
||||
CGO_ENABLED=1 go test ./internal/database/... -v
|
||||
```
|
||||
|
||||
@@ -8,19 +8,19 @@
|
||||
|
||||
**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
|
||||
@@ -139,7 +139,7 @@ func runServer() error {
|
||||
|
||||
Create `README.md`:
|
||||
|
||||
```markdown
|
||||
````markdown
|
||||
# GitM - Gitea Repository Sync Tool
|
||||
|
||||
Cross-platform tool to synchronize all repositories from multiple Gitea servers to local storage.
|
||||
@@ -157,7 +157,7 @@ Cross-platform tool to synchronize all repositories from multiple Gitea servers
|
||||
|
||||
```bash
|
||||
make all
|
||||
```
|
||||
````
|
||||
|
||||
## Run
|
||||
|
||||
@@ -184,23 +184,24 @@ 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`:
|
||||
@@ -319,13 +320,13 @@ 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`
|
||||
|
||||
- Create: `internal/database/database.go`
|
||||
- [ ] **Step 1: Create database package**
|
||||
|
||||
Create `internal/database/database.go`:
|
||||
@@ -655,14 +656,14 @@ 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`:
|
||||
@@ -961,16 +962,16 @@ 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`:
|
||||
@@ -1145,14 +1146,14 @@ 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`:
|
||||
@@ -1532,15 +1533,15 @@ 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`
|
||||
|
||||
- Create: `internal/handler/server.go`
|
||||
- [ ] **Step 1: Create server handler**
|
||||
|
||||
Create `internal/handler/server.go`:
|
||||
@@ -1885,13 +1886,13 @@ 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`
|
||||
|
||||
- Create: `internal/handler/log.go`
|
||||
- [ ] **Step 1: Create log handler**
|
||||
|
||||
Create `internal/handler/log.go`:
|
||||
@@ -1991,13 +1992,13 @@ 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`
|
||||
|
||||
- Modify: `main.go`
|
||||
- [ ] **Step 1: Update main.go with full server implementation**
|
||||
|
||||
Replace `main.go` with:
|
||||
@@ -2204,20 +2205,20 @@ 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`:
|
||||
@@ -2478,15 +2479,15 @@ 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`:
|
||||
@@ -2687,13 +2688,13 @@ 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`
|
||||
|
||||
- Create: `web/src/views/Login.vue`
|
||||
- [ ] **Step 1: Create Login.vue**
|
||||
|
||||
Create `web/src/views/Login.vue`:
|
||||
@@ -2814,13 +2815,13 @@ 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`
|
||||
|
||||
- Create: `web/src/views/Layout.vue`
|
||||
- [ ] **Step 1: Create Layout.vue**
|
||||
|
||||
Create `web/src/views/Layout.vue`:
|
||||
@@ -2962,13 +2963,13 @@ 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`
|
||||
|
||||
- Create: `web/src/views/Dashboard.vue`
|
||||
- [ ] **Step 1: Create Dashboard.vue**
|
||||
|
||||
Create `web/src/views/Dashboard.vue`:
|
||||
@@ -3162,13 +3163,13 @@ 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`
|
||||
|
||||
- Create: `web/src/views/Servers.vue`
|
||||
- [ ] **Step 1: Create Servers.vue**
|
||||
|
||||
Create `web/src/views/Servers.vue`:
|
||||
@@ -3433,15 +3434,15 @@ 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`:
|
||||
@@ -3847,16 +3848,16 @@ git add web/src/views/Repos.vue web/src/views/Logs.vue web/src/views/Settings.vu
|
||||
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:
|
||||
@@ -3904,7 +3905,7 @@ Edit `main.go`, update the `runServer` function. Find the comment about static f
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `"net/http"` to imports**
|
||||
- [ ] **Step 3: Add** **`"net/http"`** **to imports**
|
||||
|
||||
Edit `main.go` imports:
|
||||
|
||||
@@ -4055,16 +4056,16 @@ 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`:
|
||||
@@ -4205,19 +4206,19 @@ 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
|
||||
````markdown
|
||||
# GitM - Gitea Repository Sync Tool
|
||||
|
||||
Cross-platform tool to synchronize all repositories from multiple Gitea servers to local storage.
|
||||
@@ -4246,7 +4247,7 @@ cd gitm
|
||||
make all
|
||||
|
||||
# The binary will be at bin/gitm
|
||||
```
|
||||
````
|
||||
|
||||
### First Run
|
||||
|
||||
@@ -4264,8 +4265,9 @@ make all
|
||||
### Access Web UI
|
||||
|
||||
Open your browser and navigate to:
|
||||
- http://localhost:9000 (default)
|
||||
- http://localhost:9090 (if you used --addr :9090)
|
||||
|
||||
- <http://localhost:9000> (default)
|
||||
- <http://localhost:9090> (if you used --addr :9090)
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -4302,6 +4304,7 @@ 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
|
||||
```
|
||||
@@ -4322,14 +4325,17 @@ Settings are stored in the SQLite database and can be changed via the Web UI:
|
||||
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
|
||||
@@ -4341,6 +4347,7 @@ The API is available at `/api/` and requires JWT authentication:
|
||||
- `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
|
||||
@@ -4361,7 +4368,7 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The dev server runs on http://localhost:5173 with API proxy to :9000.
|
||||
The dev server runs on <http://localhost:5173> with API proxy to :9000.
|
||||
|
||||
### Backend Development
|
||||
|
||||
@@ -4409,6 +4416,7 @@ gitm/
|
||||
## License
|
||||
|
||||
MIT License
|
||||
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create .gitignore**
|
||||
@@ -4416,62 +4424,71 @@ MIT License
|
||||
Create `.gitignore`:
|
||||
|
||||
```
|
||||
|
||||
# Binaries
|
||||
|
||||
bin/
|
||||
gitm
|
||||
gitm.exe
|
||||
gitm-linux
|
||||
|
||||
# Data
|
||||
|
||||
data/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
\*.db
|
||||
\*.db-shm
|
||||
\*.db-wal
|
||||
|
||||
# Go
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.test
|
||||
*.out
|
||||
|
||||
\*.exe
|
||||
\*.exe\~
|
||||
\*.dll
|
||||
\*.so
|
||||
\*.dylib
|
||||
\*.test
|
||||
\*.out
|
||||
go.work
|
||||
|
||||
# Node
|
||||
web/node_modules/
|
||||
|
||||
web/node\_modules/
|
||||
web/dist/
|
||||
|
||||
# IDE
|
||||
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
\*.swp
|
||||
\*.swo
|
||||
\*\~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
.DS\_Store
|
||||
Thumbs.db
|
||||
|
||||
# Temporary
|
||||
*.log
|
||||
*.tmp
|
||||
```
|
||||
|
||||
\*.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)
|
||||
|
||||
- None (verification task)
|
||||
- [ ] **Step 1: Full clean build**
|
||||
|
||||
```bash
|
||||
@@ -4537,7 +4554,7 @@ git add -A
|
||||
git commit -m "chore: final build verification complete"
|
||||
```
|
||||
|
||||
---
|
||||
***
|
||||
|
||||
## Implementation Complete
|
||||
|
||||
@@ -4566,3 +4583,4 @@ The GitM project is now fully implemented with:
|
||||
6. Add more detailed sync progress reporting
|
||||
7. Implement backup/restore functionality
|
||||
8. Add Docker support
|
||||
|
||||
|
||||
108
internal/database/database_test.go
Normal file
108
internal/database/database_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"gitm/internal/models"
|
||||
)
|
||||
|
||||
func TestDatabase(t *testing.T) {
|
||||
tmpDB := "/tmp/test_gitm.db"
|
||||
defer os.Remove(tmpDB)
|
||||
|
||||
if err := Initialize(tmpDB); err != nil {
|
||||
t.Fatalf("Initialize failed: %v", err)
|
||||
}
|
||||
defer Close()
|
||||
|
||||
t.Run("Settings", func(t *testing.T) {
|
||||
if err := SetSetting("test_key", "test_value"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
val, err := GetSetting("test_key")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if val != "test_value" {
|
||||
t.Errorf("expected test_value, got %s", val)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ServerCRUD", func(t *testing.T) {
|
||||
s := &models.GiteaServer{Name: "Test", URL: "https://test.com", Token: "tok", Status: "active"}
|
||||
if err := CreateServer(s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.ID == 0 {
|
||||
t.Error("ID not set")
|
||||
}
|
||||
|
||||
got, err := GetServer(s.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Name != "Test" {
|
||||
t.Error("name mismatch")
|
||||
}
|
||||
|
||||
servers, err := GetServers()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(servers) != 1 {
|
||||
t.Errorf("expected 1, got %d", len(servers))
|
||||
}
|
||||
|
||||
if err := DeleteServer(s.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RepoCRUD", func(t *testing.T) {
|
||||
s := &models.GiteaServer{Name: "RepoTest", URL: "https://test.com", Token: "tok", Status: "active"}
|
||||
CreateServer(s)
|
||||
|
||||
r := &models.Repo{ServerID: s.ID, Name: "repo1", FullName: "user/repo1", CloneURL: "https://test.com/user/repo1.git", SyncStatus: "pending"}
|
||||
if err := CreateRepo(r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := GetRepoByFullName(s.ID, "user/repo1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil || got.Name != "repo1" {
|
||||
t.Error("repo not found")
|
||||
}
|
||||
|
||||
repos, err := GetReposByServer(s.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(repos) != 1 {
|
||||
t.Errorf("expected 1, got %d", len(repos))
|
||||
}
|
||||
|
||||
DeleteServer(s.ID)
|
||||
})
|
||||
|
||||
t.Run("SyncLogs", func(t *testing.T) {
|
||||
s := &models.GiteaServer{Name: "LogTest", URL: "https://test.com", Token: "tok", Status: "active"}
|
||||
CreateServer(s)
|
||||
|
||||
l := &models.SyncLog{ServerID: s.ID, Status: "success", Message: "test"}
|
||||
if err := CreateSyncLog(l); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
logs, err := GetSyncLogs(s.ID, 10, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(logs) != 1 {
|
||||
t.Errorf("expected 1, got %d", len(logs))
|
||||
}
|
||||
|
||||
DeleteServer(s.ID)
|
||||
})
|
||||
}
|
||||
98
internal/gitea/client.go
Normal file
98
internal/gitea/client.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL *url.URL
|
||||
httpClient *http.Client
|
||||
token string
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (c *Client) do(path string) (*http.Response, error) {
|
||||
apiURL := c.baseURL.JoinPath(path)
|
||||
q := apiURL.Query()
|
||||
q.Set("token", c.token)
|
||||
apiURL.RawQuery = q.Encode()
|
||||
resp, err := c.httpClient.Get(apiURL.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (c *Client) SearchRepos(page, limit int) ([]GiteaRepo, error) {
|
||||
path := fmt.Sprintf("/api/v1/repos/search?page=%d&limit=%d", page, limit)
|
||||
resp, err := c.do(path)
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
20
internal/gitea/client_test.go
Normal file
20
internal/gitea/client_test.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package gitea
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
client, err := NewClient("https://gitea.com", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if client == nil {
|
||||
t.Error("client is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientInvalidURL(t *testing.T) {
|
||||
_, err := NewClient("://invalid", "")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid URL")
|
||||
}
|
||||
}
|
||||
25
internal/gitea/types.go
Normal file
25
internal/gitea/types.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package gitea
|
||||
|
||||
type GiteaUser struct {
|
||||
ID int64 `json:"id"`
|
||||
Login string `json:"login"`
|
||||
FullName string `json:"full_name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type GiteaSearchResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Data []GiteaRepo `json:"data"`
|
||||
}
|
||||
61
internal/handler/log.go
Normal file
61
internal/handler/log.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gitm/internal/database"
|
||||
)
|
||||
|
||||
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
|
||||
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)})
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
238
internal/handler/server.go
Normal file
238
internal/handler/server.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gitm/internal/database"
|
||||
"gitm/internal/gitea"
|
||||
"gitm/internal/models"
|
||||
"gitm/internal/sync"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func HandleCreateServer(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
URL string `json:"url" binding:"required"`
|
||||
Token string `json:"token" binding:"required"`
|
||||
SyncInterval int `json:"sync_interval"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
server := &models.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)
|
||||
}
|
||||
|
||||
func HandleUpdateServer(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 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 struct {
|
||||
Name *string `json:"name"`
|
||||
URL *string `json:"url"`
|
||||
Token *string `json:"token"`
|
||||
SyncInterval *int `json:"sync_interval"`
|
||||
Status *string `json:"status"`
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
func HandleDeleteServer(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 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"})
|
||||
}
|
||||
|
||||
func HandleTestConnection(c *gin.Context) {
|
||||
var req struct {
|
||||
URL string `json:"url" binding:"required"`
|
||||
Token string `json:"token" binding:"required"`
|
||||
}
|
||||
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})
|
||||
}
|
||||
|
||||
func HandleListRepos(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 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)
|
||||
}
|
||||
|
||||
func HandleDiscoverRepos(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 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
|
||||
}
|
||||
reposDir, _ := database.GetSetting("repos_dir")
|
||||
if reposDir == "" {
|
||||
reposDir = "./data/repos"
|
||||
}
|
||||
serverDir := reposDir + "/" + strconv.FormatInt(id, 10) + "_" + server.Name
|
||||
discovered := 0
|
||||
for _, gr := range giteaRepos {
|
||||
existing, _ := database.GetRepoByFullName(id, gr.FullName)
|
||||
if existing == nil {
|
||||
repo := &models.Repo{
|
||||
ServerID: id,
|
||||
Name: gr.Name,
|
||||
FullName: gr.FullName,
|
||||
CloneURL: gr.CloneURL,
|
||||
LocalPath: serverDir + "/" + gr.FullName + ".git",
|
||||
SyncStatus: "pending",
|
||||
}
|
||||
database.CreateRepo(repo)
|
||||
discovered++
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": fmt.Sprintf("Discovered %d new repositories", discovered),
|
||||
"total_repos": len(giteaRepos),
|
||||
"new_repos": discovered,
|
||||
})
|
||||
}
|
||||
|
||||
func HandleSyncServer(engine *sync.Engine) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 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})
|
||||
}
|
||||
}
|
||||
|
||||
func HandleSyncAll(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)})
|
||||
}
|
||||
}
|
||||
|
||||
func HandleGetSyncStatus(engine *sync.Engine) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"})
|
||||
return
|
||||
}
|
||||
status := "idle"
|
||||
if engine.IsSyncing(id) {
|
||||
status = "syncing"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"server_id": id, "status": status})
|
||||
}
|
||||
}
|
||||
190
internal/sync/engine.go
Normal file
190
internal/sync/engine.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitm/internal/database"
|
||||
"gitm/internal/gitea"
|
||||
"gitm/internal/models"
|
||||
)
|
||||
|
||||
type Engine struct {
|
||||
maxConcurrent int
|
||||
mu sync.Mutex
|
||||
activeTasks map[int64]string
|
||||
}
|
||||
|
||||
func NewEngine(maxConcurrent int) *Engine {
|
||||
return &Engine{
|
||||
maxConcurrent: maxConcurrent,
|
||||
activeTasks: make(map[int64]string),
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}()
|
||||
|
||||
server, err := database.GetServer(serverID)
|
||||
if err != nil || server == nil {
|
||||
return fmt.Errorf("failed to get server: %w", err)
|
||||
}
|
||||
|
||||
syncLog := &models.SyncLog{
|
||||
ServerID: serverID,
|
||||
Status: "in_progress",
|
||||
Message: "Starting sync",
|
||||
StartedAt: time.Now(),
|
||||
}
|
||||
if err := database.CreateSyncLog(syncLog); err != nil {
|
||||
return fmt.Errorf("failed to create sync log: %w", err)
|
||||
}
|
||||
|
||||
client, err := gitea.NewClient(server.URL, server.Token)
|
||||
if err != nil {
|
||||
e.finishLog(syncLog, "failed", fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := client.ValidateToken(); err != nil {
|
||||
e.finishLog(syncLog, "failed", fmt.Sprintf("Authentication failed: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
giteaRepos, err := client.GetAllRepos()
|
||||
if err != nil {
|
||||
e.finishLog(syncLog, "failed", fmt.Sprintf("Failed to fetch repos: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
reposDir, _ := database.GetSetting("repos_dir")
|
||||
if reposDir == "" {
|
||||
reposDir = "./data/repos"
|
||||
}
|
||||
serverDir := filepath.Join(reposDir, fmt.Sprintf("server_%d_%s", serverID, server.Name))
|
||||
os.MkdirAll(serverDir, 0755)
|
||||
|
||||
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 }()
|
||||
|
||||
localPath := filepath.Join(serverDir, giteaRepo.FullName+".git")
|
||||
existingRepo, _ := database.GetRepoByFullName(serverID, giteaRepo.FullName)
|
||||
|
||||
var err error
|
||||
if existingRepo == nil {
|
||||
err = e.cloneMirror(giteaRepo.CloneURL, localPath)
|
||||
if err == nil {
|
||||
repo := &models.Repo{
|
||||
ServerID: serverID,
|
||||
Name: giteaRepo.Name,
|
||||
FullName: giteaRepo.FullName,
|
||||
CloneURL: giteaRepo.CloneURL,
|
||||
LocalPath: localPath,
|
||||
SyncStatus: "success",
|
||||
}
|
||||
database.CreateRepo(repo)
|
||||
}
|
||||
} else {
|
||||
err = e.fetchMirror(localPath)
|
||||
if err == nil {
|
||||
database.UpdateRepoSyncStatus(existingRepo.ID, "success")
|
||||
}
|
||||
}
|
||||
|
||||
resultsMu.Lock()
|
||||
if err != nil {
|
||||
failedCount++
|
||||
failLog := &models.SyncLog{
|
||||
ServerID: serverID,
|
||||
RepoID: getRepoID(serverID, giteaRepo.FullName),
|
||||
Status: "failed",
|
||||
Message: err.Error(),
|
||||
StartedAt: time.Now(),
|
||||
}
|
||||
database.CreateSyncLog(failLog)
|
||||
} else {
|
||||
successCount++
|
||||
}
|
||||
resultsMu.Unlock()
|
||||
}(gr)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
database.UpdateServerLastSync(serverID)
|
||||
|
||||
message := fmt.Sprintf("Sync completed: %d succeeded, %d failed", successCount, failedCount)
|
||||
e.finishLog(syncLog, "success", message)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) cloneMirror(url, path string) error {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return nil
|
||||
}
|
||||
os.MkdirAll(filepath.Dir(path), 0755)
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (e *Engine) finishLog(log *models.SyncLog, status, message string) {
|
||||
now := time.Now()
|
||||
log.Status = status
|
||||
log.Message = message
|
||||
log.FinishedAt = &now
|
||||
database.UpdateSyncLog(log.ID, status, message, log.FinishedAt)
|
||||
}
|
||||
|
||||
func (e *Engine) IsSyncing(serverID int64) bool {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
_, active := e.activeTasks[serverID]
|
||||
return active
|
||||
}
|
||||
|
||||
func getRepoID(serverID int64, fullName string) *int64 {
|
||||
repo, _ := database.GetRepoByFullName(serverID, fullName)
|
||||
if repo != nil {
|
||||
return &repo.ID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
74
internal/sync/scheduler.go
Normal file
74
internal/sync/scheduler.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
"gitm/internal/database"
|
||||
"gitm/internal/models"
|
||||
)
|
||||
|
||||
type Scheduler struct {
|
||||
cron *cron.Cron
|
||||
engine *Engine
|
||||
serverJobs map[int64]cron.EntryID
|
||||
}
|
||||
|
||||
func NewScheduler(engine *Engine) *Scheduler {
|
||||
return &Scheduler{
|
||||
cron: cron.New(),
|
||||
engine: engine,
|
||||
serverJobs: make(map[int64]cron.EntryID),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) Start() {
|
||||
s.cron.Start()
|
||||
log.Println("Scheduler started")
|
||||
}
|
||||
|
||||
func (s *Scheduler) Stop() {
|
||||
s.cron.Stop()
|
||||
log.Println("Scheduler stopped")
|
||||
}
|
||||
|
||||
func (s *Scheduler) UpdateServer(server *models.GiteaServer) {
|
||||
if entryID, exists := s.serverJobs[server.ID]; exists {
|
||||
s.cron.Remove(entryID)
|
||||
delete(s.serverJobs, server.ID)
|
||||
}
|
||||
if server.SyncInterval <= 0 || server.Status != "active" {
|
||||
return
|
||||
}
|
||||
schedule := fmt.Sprintf("*/%d * * * *", server.SyncInterval)
|
||||
entryID, err := s.cron.AddFunc(schedule, func() {
|
||||
log.Printf("Scheduled sync 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 for server %d: %v", server.ID, err)
|
||||
return
|
||||
}
|
||||
s.serverJobs[server.ID] = entryID
|
||||
}
|
||||
|
||||
func (s *Scheduler) RemoveServer(serverID int64) {
|
||||
if entryID, exists := s.serverJobs[serverID]; exists {
|
||||
s.cron.Remove(entryID)
|
||||
delete(s.serverJobs, serverID)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) ReloadAll() error {
|
||||
servers, err := database.GetServers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, server := range servers {
|
||||
s.UpdateServer(&server)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
127
main.go
127
main.go
@@ -1,32 +1,141 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
//go:embed all:web/dist
|
||||
var webFS embed.FS
|
||||
|
||||
var (
|
||||
flagAddr = flag.String("addr", ":9000", "Listen address")
|
||||
flagDataDir = flag.String("data", "./data", "Data directory")
|
||||
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)
|
||||
} else {
|
||||
cfg.SetDataDir(cfg.DataDir)
|
||||
}
|
||||
if *flagAddr != "" {
|
||||
cfg.ListenAddr = *flagAddr
|
||||
}
|
||||
if err := cfg.EnsureDirs(); err != nil {
|
||||
log.Fatalf("Failed to create directories: %v", err)
|
||||
}
|
||||
if err := database.Initialize(cfg.DBPath); err != nil {
|
||||
log.Fatalf("Failed to initialize database: %v", err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
fmt.Printf("GitM - Gitea Repository Sync Tool\n")
|
||||
fmt.Printf("Listen: %s\n", *flagAddr)
|
||||
fmt.Printf("Data: %s\n", *flagDataDir)
|
||||
if listenAddr, err := database.GetSetting("listen_addr"); err == nil && listenAddr != "" {
|
||||
cfg.ListenAddr = listenAddr
|
||||
}
|
||||
maxConcurrent := 3
|
||||
if maxStr, err := database.GetSetting("max_concurrent"); err == nil && maxStr != "" {
|
||||
fmt.Sscanf(maxStr, "%d", &maxConcurrent)
|
||||
}
|
||||
|
||||
engine := sync.NewEngine(maxConcurrent)
|
||||
scheduler := sync.NewScheduler(engine)
|
||||
|
||||
if *flagInit {
|
||||
fmt.Println("Initialize mode: TODO")
|
||||
runInitMode()
|
||||
return
|
||||
}
|
||||
|
||||
log.Fatal(runServer())
|
||||
if pwd, _ := database.GetSetting("admin_password"); pwd == "" {
|
||||
fmt.Println("Not initialized. Please run with --init flag first.")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func runServer() error {
|
||||
return fmt.Errorf("not implemented")
|
||||
middleware.SetJWTSecret("gitm-default-secret")
|
||||
scheduler.ReloadAll()
|
||||
scheduler.Start()
|
||||
defer scheduler.Stop()
|
||||
|
||||
log.Printf("GitM starting on %s", cfg.ListenAddr)
|
||||
log.Fatal(runServer(cfg, engine))
|
||||
}
|
||||
|
||||
func runInitMode() {
|
||||
var password string
|
||||
fmt.Print("Enter admin password: ")
|
||||
fmt.Scanln(&password)
|
||||
if password == "" {
|
||||
log.Fatal("Password cannot be empty")
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to hash password: %v", err)
|
||||
}
|
||||
database.SetSetting("admin_password", string(hash))
|
||||
database.SetSetting("listen_addr", ":9000")
|
||||
database.SetSetting("max_concurrent", "3")
|
||||
fmt.Println("Initialized successfully!")
|
||||
}
|
||||
|
||||
func runServer(cfg *config.Config, engine *sync.Engine) error {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.Default()
|
||||
|
||||
// Serve embedded frontend
|
||||
distFS, err := fs.Sub(webFS, "web/dist")
|
||||
if err == nil {
|
||||
// Serve assets
|
||||
r.StaticFS("/assets", http.FS(distFS))
|
||||
// SPA fallback - serve index.html for non-API routes
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
if !strings.HasPrefix(c.Request.URL.Path, "/api") {
|
||||
data, _ := webFS.ReadFile("web/dist/index.html")
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
|
||||
} else {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
api := r.Group("/api")
|
||||
{
|
||||
api.POST("/login", handler.HandleLogin)
|
||||
api.POST("/init", handler.HandleInit)
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.AuthMiddleware())
|
||||
{
|
||||
protected.GET("/settings", handler.HandleGetSettings)
|
||||
protected.PUT("/settings", handler.HandleUpdateSettings)
|
||||
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))
|
||||
protected.POST("/sync/all", handler.HandleSyncAll(engine))
|
||||
protected.GET("/sync/logs", handler.HandleGetSyncLogs)
|
||||
protected.GET("/sync/stats", handler.HandleGetStats)
|
||||
}
|
||||
}
|
||||
return r.Run(cfg.ListenAddr)
|
||||
}
|
||||
|
||||
12
web/index.html
Normal file
12
web/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!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>
|
||||
1774
web/package-lock.json
generated
Normal file
1774
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
web/package.json
Normal file
23
web/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "gitm-web",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "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",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
3
web/src/App.vue
Normal file
3
web/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
46
web/src/api/index.ts
Normal file
46
web/src/api/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({ baseURL: '/api' })
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
(r) => r,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export const authApi = {
|
||||
login: (password: string) => api.post('/login', { password }),
|
||||
getSettings: () => api.get('/settings'),
|
||||
updateSettings: (data: any) => api.put('/settings', data),
|
||||
}
|
||||
|
||||
export const serverApi = {
|
||||
list: () => api.get('/servers'),
|
||||
create: (data: any) => api.post('/servers', data),
|
||||
update: (id: number, data: any) => api.put(`/servers/${id}`, data),
|
||||
delete: (id: number) => api.delete(`/servers/${id}`),
|
||||
test: (data: any) => api.post('/servers/test', data),
|
||||
getRepos: (id: number) => api.get(`/servers/${id}/repos`),
|
||||
discover: (id: number) => api.post(`/servers/${id}/discover`),
|
||||
sync: (id: number) => api.post(`/servers/${id}/sync`),
|
||||
syncStatus: (id: number) => api.get(`/servers/${id}/sync/status`),
|
||||
}
|
||||
|
||||
export const syncApi = {
|
||||
syncAll: () => api.post('/sync/all'),
|
||||
getLogs: (params?: any) => api.get('/sync/logs', { params }),
|
||||
getStats: () => api.get('/sync/stats'),
|
||||
}
|
||||
|
||||
export default api
|
||||
39
web/src/api/types.ts
Normal file
39
web/src/api/types.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
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
|
||||
}
|
||||
6
web/src/env.d.ts
vendored
Normal file
6
web/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
16
web/src/main.ts
Normal file
16
web/src/main.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
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)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
app.mount('#app')
|
||||
37
web/src/router/index.ts
Normal file
37
web/src/router/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
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')
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/views/Layout.vue'),
|
||||
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 token = localStorage.getItem('token')
|
||||
if (to.path !== '/login' && !token) {
|
||||
next('/login')
|
||||
} else if (to.path === '/login' && token) {
|
||||
next('/')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
27
web/src/stores/auth.ts
Normal file
27
web/src/stores/auth.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
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 res = await authApi.login(password)
|
||||
token.value = res.data.token
|
||||
localStorage.setItem('token', res.data.token)
|
||||
return true
|
||||
} catch { return false }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = null
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
|
||||
return { token, loading, isAuthenticated, login, logout }
|
||||
})
|
||||
53
web/src/views/Dashboard.vue
Normal file
53
web/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div style="padding:20px">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="6"><el-card shadow="hover"><el-statistic title="Total Servers" :value="stats.server_count" /></el-card></el-col>
|
||||
<el-col :span="6"><el-card shadow="hover"><el-statistic title="Active Servers" :value="stats.active_servers" /></el-card></el-col>
|
||||
<el-col :span="6"><el-card shadow="hover"><el-statistic title="Total Repos" :value="stats.repo_count" /></el-card></el-col>
|
||||
<el-col :span="6"><el-card shadow="hover"><el-statistic title="Total Size" :value="formatSize(stats.total_size)" /></el-card></el-col>
|
||||
</el-row>
|
||||
<el-card style="margin-top:20px">
|
||||
<template #header><span>Quick Actions</span></template>
|
||||
<el-space>
|
||||
<el-button type="primary" :loading="syncing" @click="syncAll">Sync All</el-button>
|
||||
<el-button @click="$router.push('/servers')">Add Server</el-button>
|
||||
</el-space>
|
||||
</el-card>
|
||||
<el-card style="margin-top:20px">
|
||||
<template #header><div style="display:flex;justify-content:space-between"><span>Recent Activity</span><el-button text @click="$router.push('/logs')">View All</el-button></div></template>
|
||||
<el-table :data="logs" stripe>
|
||||
<el-table-column prop="started_at" label="Time" width="180"><template #default="{row}">{{ formatDate(row.started_at) }}</template></el-table-column>
|
||||
<el-table-column prop="server_id" label="Server" width="80" />
|
||||
<el-table-column prop="status" label="Status" width="100"><template #default="{row}"><el-tag :type="row.status==='success'?'success':row.status==='failed'?'danger':'info'">{{row.status}}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="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'
|
||||
|
||||
const stats = ref({ server_count:0, active_servers:0, repo_count:0, total_size:0 })
|
||||
const logs = ref<any[]>([])
|
||||
const syncing = ref(false)
|
||||
|
||||
async function loadData() {
|
||||
try { stats.value = (await syncApi.getStats()).data } catch {}
|
||||
try { logs.value = (await syncApi.getLogs({limit:10})).data.data || [] } catch {}
|
||||
}
|
||||
|
||||
async function syncAll() {
|
||||
syncing.value = true
|
||||
try { await syncApi.syncAll(); ElMessage.success('Sync started'); setTimeout(loadData, 1000) }
|
||||
catch { ElMessage.error('Failed') }
|
||||
finally { syncing.value = false }
|
||||
}
|
||||
|
||||
function formatSize(b:number) { if(!b)return '0 B'; const k=1024,s=['B','KB','MB','GB']; const i=Math.floor(Math.log(b)/Math.log(k)); return (b/Math.pow(k,i)).toFixed(1)+' '+s[i] }
|
||||
function formatDate(d:string) { return new Date(d).toLocaleString() }
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
42
web/src/views/Layout.vue
Normal file
42
web/src/views/Layout.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<el-container style="height:100vh">
|
||||
<el-aside width="200px" style="background:#001529">
|
||||
<div style="padding:20px;text-align:center;border-bottom:1px solid #1f1f1f">
|
||||
<h2 style="margin:0;color:#fff">GitM</h2>
|
||||
</div>
|
||||
<el-menu :default-active="$route.path" router background-color="#001529" text-color="#fff" active-text-color="#1890ff">
|
||||
<el-menu-item index="/"><el-icon><DataBoard /></el-icon><span>Dashboard</span></el-menu-item>
|
||||
<el-menu-item index="/servers"><el-icon><Monitor /></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 style="background:#fff;border-bottom:1px solid #f0f0f0;display:flex;align-items:center">
|
||||
<div style="width:100%;display:flex;justify-content:space-between;align-items:center">
|
||||
<span style="font-size:18px;font-weight:500">{{ pageTitle }}</span>
|
||||
<el-button text @click="handleLogout"><el-icon><SwitchButton /></el-icon> Logout</el-button>
|
||||
</div>
|
||||
</el-header>
|
||||
<el-main style="background:#f5f5f5"><router-view /></el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
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>
|
||||
44
web/src/views/Login.vue
Normal file
44
web/src/views/Login.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div style="display:flex;align-items:center;justify-content:center;height:100vh;background:linear-gradient(135deg,#667eea,#764ba2)">
|
||||
<el-card style="width:400px">
|
||||
<template #header>
|
||||
<div style="text-align:center">
|
||||
<h1 style="margin:0;color:#409eff">GitM</h1>
|
||||
<p style="margin:5px 0 0;color:#909399;font-size:14px">Gitea Repository Sync Tool</p>
|
||||
</div>
|
||||
</template>
|
||||
<el-form @submit.prevent="handleLogin">
|
||||
<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="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:16px" />
|
||||
</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('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function handleLogin() {
|
||||
if (!password.value) { error.value = 'Please enter password'; return }
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
const ok = await authStore.login(password.value)
|
||||
loading.value = false
|
||||
if (ok) { ElMessage.success('Login successful'); router.push('/') }
|
||||
else { error.value = 'Invalid password' }
|
||||
}
|
||||
</script>
|
||||
36
web/src/views/Logs.vue
Normal file
36
web/src/views/Logs.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div style="padding:20px">
|
||||
<el-card>
|
||||
<template #header><div style="display:flex;justify-content:space-between;align-items:center"><span>Sync Logs</span>
|
||||
<el-select v-model="filterServer" clearable style="width:200px"><el-option v-for="s in servers" :key="s.id" :label="s.name" :value="s.id" /></el-select>
|
||||
</div></template>
|
||||
<el-table :data="logs" stripe v-loading="loading">
|
||||
<el-table-column prop="started_at" label="Time" width="180"><template #default="{row}">{{new Date(row.started_at).toLocaleString()}}</template></el-table-column>
|
||||
<el-table-column prop="server_id" label="Server" width="80" />
|
||||
<el-table-column prop="status" label="Status" width="100"><template #default="{row}"><el-tag :type="row.status==='success'?'success':row.status==='failed'?'danger':'info'">{{row.status}}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="message" label="Message" />
|
||||
</el-table>
|
||||
<el-pagination style="margin-top:20px;justify-content:center" layout="prev,pager,next" :page-size="50" @current-change="loadLogs" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { serverApi, syncApi } from '@/api'
|
||||
|
||||
const servers=ref<any[]>([])
|
||||
const logs=ref<any[]>([])
|
||||
const loading=ref(false)
|
||||
const filterServer=ref<number|null>(null)
|
||||
|
||||
async function load(){try{servers.value=(await serverApi.list()).data}catch{}}
|
||||
async function loadLogs(page=1){
|
||||
loading.value=true
|
||||
try{const params:any={page,limit:50};if(filterServer.value)params.server_id=filterServer.value;const r=(await syncApi.getLogs(params)).data;logs.value=r.data||[];console.log(r)}
|
||||
catch{}finally{loading.value=false}
|
||||
}
|
||||
|
||||
watch(filterServer,()=>loadLogs(1))
|
||||
onMounted(async()=>{await load();loadLogs()})
|
||||
</script>
|
||||
45
web/src/views/Repos.vue
Normal file
45
web/src/views/Repos.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div style="padding:20px">
|
||||
<el-card>
|
||||
<template #header><div style="display:flex;justify-content:space-between;align-items:center"><span>Repositories</span>
|
||||
<el-select v-model="selectedServer" style="width:200px"><el-option label="All Servers" :value="0" /><el-option v-for="s in servers" :key="s.id" :label="s.name" :value="s.id" /></el-select>
|
||||
</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" width="80" />
|
||||
<el-table-column prop="size" label="Size" width="120"><template #default="{row}">{{formatSize(row.size)}}</template></el-table-column>
|
||||
<el-table-column prop="sync_status" label="Status" width="100"><template #default="{row}"><el-tag :type="row.sync_status==='success'?'success':row.sync_status==='failed'?'danger':row.sync_status==='syncing'?'warning':'info'">{{row.sync_status}}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="last_sync_at" label="Last Sync" width="180"><template #default="{row}">{{row.last_sync_at?new Date(row.last_sync_at).toLocaleString():'Never'}}</template></el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { serverApi } from '@/api'
|
||||
|
||||
const servers = ref<any[]>([])
|
||||
const repos = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const selectedServer = ref(0)
|
||||
|
||||
async function load() { try { servers.value=(await serverApi.list()).data } catch{} }
|
||||
async function loadRepos() {
|
||||
loading.value=true
|
||||
try {
|
||||
const all:any[]=[]
|
||||
for(const s of servers.value) {
|
||||
if(selectedServer.value===0||selectedServer.value===s.id) {
|
||||
const r=(await serverApi.getRepos(s.id)).data; all.push(...r)
|
||||
}
|
||||
}
|
||||
repos.value=all
|
||||
} catch{} finally{loading.value=false}
|
||||
}
|
||||
|
||||
function formatSize(b:number){if(!b)return'0 B';const k=1024,s=['B','KB','MB','GB'];const i=Math.floor(Math.log(b)/Math.log(k));return(b/Math.pow(k,i)).toFixed(1)+' '+s[i]}
|
||||
|
||||
watch(selectedServer,loadRepos)
|
||||
onMounted(async()=>{await load();loadRepos()})
|
||||
</script>
|
||||
78
web/src/views/Servers.vue
Normal file
78
web/src/views/Servers.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div style="padding:20px">
|
||||
<el-card>
|
||||
<template #header><div style="display:flex;justify-content:space-between;align-items:center"><span>Gitea Servers</span><el-button type="primary" @click="showDialog=true;editMode=false;form={name:'',url:'',token:'',sync_interval:0}">Add Server</el-button></div></template>
|
||||
<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?new Date(row.last_sync_at).toLocaleString():'Never'}}</template></el-table-column>
|
||||
<el-table-column label="Actions" width="280" fixed="right">
|
||||
<template #default="{row}">
|
||||
<el-button size="small" @click="discover(row)">Discover</el-button>
|
||||
<el-button size="small" type="primary" @click="syncOne(row)">Sync</el-button>
|
||||
<el-button size="small" @click="edit(row)">Edit</el-button>
|
||||
<el-button size="small" type="danger" @click="remove(row)">Delete</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="showDialog" :title="editMode?'Edit Server':'Add Server'" width="500px">
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item label="Name"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="URL"><el-input v-model="form.url" placeholder="https://git.example.com" /></el-form-item>
|
||||
<el-form-item label="Token"><el-input v-model="form.token" type="password" show-password /></el-form-item>
|
||||
<el-form-item label="Sync Interval"><el-input-number v-model="form.sync_interval" :min="0" :max="1440" /><span style="margin-left:10px;color:#909399">min (0=manual)</span></el-form-item>
|
||||
</el-form>
|
||||
<el-space style="margin-top:16px">
|
||||
<el-button type="primary" @click="save">Save</el-button>
|
||||
<el-button @click="testConn">Test Connection</el-button>
|
||||
<el-button @click="showDialog=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'
|
||||
|
||||
const servers = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const showDialog = ref(false)
|
||||
const editMode = ref(false)
|
||||
const editingId = ref(0)
|
||||
const form = ref({ name:'', url:'', token:'', sync_interval:0 })
|
||||
|
||||
async function load() { loading.value=true; try { servers.value=(await serverApi.list()).data } catch{} finally{loading.value=false} }
|
||||
|
||||
function edit(row:any) { editMode.value=true; editingId.value=row.id; form.value={name:row.name,url:row.url,token:'',sync_interval:row.sync_interval}; showDialog.value=true }
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
if(editMode.value) {
|
||||
const data:any={name:form.value.name,url:form.value.url,sync_interval:form.value.sync_interval}
|
||||
if(form.value.token) data.token=form.value.token
|
||||
await serverApi.update(editingId.value, data)
|
||||
} else {
|
||||
await serverApi.create(form.value)
|
||||
}
|
||||
ElMessage.success(editMode.value?'Updated':'Added'); showDialog.value=false; load()
|
||||
} catch { ElMessage.error('Failed') }
|
||||
}
|
||||
|
||||
async function testConn() {
|
||||
try { const r=(await serverApi.test({url:form.value.url,token:form.value.token})).data; ElMessage.success('Connected! User: '+r.user) }
|
||||
catch { ElMessage.error('Connection failed') }
|
||||
}
|
||||
|
||||
async function discover(row:any) { try { const r=(await serverApi.discover(row.id)).data; ElMessage.success(r.message) } catch { ElMessage.error('Failed') } }
|
||||
async function syncOne(row:any) { try { await serverApi.sync(row.id); ElMessage.success('Sync started') } catch(e:any) { if(e.response?.status===409) ElMessage.warning('Already syncing'); else ElMessage.error('Failed') } }
|
||||
function remove(row:any) { ElMessageBox.confirm('Delete this server?','Confirm',{type:'warning'}).then(async()=>{ await serverApi.delete(row.id); ElMessage.success('Deleted'); load() }).catch(()=>{}) }
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
38
web/src/views/Settings.vue
Normal file
38
web/src/views/Settings.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div style="padding:20px">
|
||||
<el-card>
|
||||
<template #header><span>Settings</span></template>
|
||||
<el-form :model="form" label-width="150px" style="max-width:600px">
|
||||
<el-form-item label="New Password"><el-input v-model="form.password" type="password" show-password placeholder="Leave empty to keep" /></el-form-item>
|
||||
<el-form-item label="Listen Address"><el-input v-model="form.listen_addr" /></el-form-item>
|
||||
<el-form-item label="Repos Directory"><el-input v-model="form.repos_dir" /></el-form-item>
|
||||
<el-form-item label="Max Concurrent"><el-input-number v-model="form.max_concurrent" :min="1" :max="10" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" @click="save">Save</el-button></el-form-item>
|
||||
</el-form>
|
||||
<el-alert type="info" title="Listen address change requires restart" :closable="false" style="margin-top:16px" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { authApi } from '@/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const form=ref({password:'',listen_addr:':9000',repos_dir:'',max_concurrent:3})
|
||||
|
||||
async function load(){
|
||||
try{const r=(await authApi.getSettings()).data;form.value={password:'',listen_addr:r.listen_addr||':9000',repos_dir:r.repos_dir||'',max_concurrent:r.max_concurrent||3}}
|
||||
catch{}
|
||||
}
|
||||
|
||||
async function save(){
|
||||
try{
|
||||
const data:any={listen_addr:form.value.listen_addr,repos_dir:form.value.repos_dir,max_concurrent:form.value.max_concurrent}
|
||||
if(form.value.password)data.admin_password=form.value.password
|
||||
await authApi.updateSettings(data);ElMessage.success('Saved');form.value.password=''
|
||||
}catch{ElMessage.error('Failed')}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
16
web/tsconfig.json
Normal file
16
web/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["src/*"] }
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
25
web/vite.config.ts
Normal file
25
web/vite.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
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'
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user