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/
|
bin/
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
web/node_modules/
|
||||||
|
web/dist/
|
||||||
*.exe
|
*.exe
|
||||||
*.test
|
*.test
|
||||||
*.out
|
*.out
|
||||||
web/dist/
|
.idea/
|
||||||
web/node_modules/
|
.vscode/
|
||||||
|
|||||||
31
README.md
31
README.md
@@ -4,7 +4,7 @@ Cross-platform tool to synchronize all repositories from multiple Gitea servers
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Single binary deployment
|
- Single binary deployment (frontend embedded)
|
||||||
- Web UI for management (Vue 3 + Element Plus)
|
- Web UI for management (Vue 3 + Element Plus)
|
||||||
- SQLite database
|
- SQLite database
|
||||||
- JWT authentication
|
- JWT authentication
|
||||||
@@ -14,20 +14,47 @@ Cross-platform tool to synchronize all repositories from multiple Gitea servers
|
|||||||
## Build
|
## Build
|
||||||
|
|
||||||
```bash
|
```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
|
## Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# First-time initialization (set admin password)
|
||||||
./bin/gitm --init
|
./bin/gitm --init
|
||||||
|
|
||||||
|
# Start server (default :9000)
|
||||||
./bin/gitm
|
./bin/gitm
|
||||||
|
|
||||||
|
# Custom address
|
||||||
./bin/gitm --addr :9090
|
./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
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Frontend dev server
|
||||||
cd web && npm install && npm run dev
|
cd web && npm install && npm run dev
|
||||||
|
|
||||||
|
# Backend
|
||||||
go run main.go
|
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
|
**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
|
## Phase 1: Project Initialization and Core Infrastructure
|
||||||
|
|
||||||
### Task 1: Initialize Go Module and Project Structure
|
### Task 1: Initialize Go Module and Project Structure
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `go.mod`
|
- Create: `go.mod`
|
||||||
- Create: `go.sum` (auto-generated)
|
- Create: `go.sum` (auto-generated)
|
||||||
- Create: `Makefile`
|
- Create: `Makefile`
|
||||||
- Create: `main.go`
|
- Create: `main.go`
|
||||||
- Create: `README.md`
|
- Create: `README.md`
|
||||||
|
|
||||||
- [ ] **Step 1: Initialize Go module**
|
- [ ] **Step 1: Initialize Go module**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -139,7 +139,7 @@ func runServer() error {
|
|||||||
|
|
||||||
Create `README.md`:
|
Create `README.md`:
|
||||||
|
|
||||||
```markdown
|
````markdown
|
||||||
# GitM - Gitea Repository Sync Tool
|
# GitM - Gitea Repository Sync Tool
|
||||||
|
|
||||||
Cross-platform tool to synchronize all repositories from multiple Gitea servers to local storage.
|
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
|
```bash
|
||||||
make all
|
make all
|
||||||
```
|
````
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
@@ -184,23 +184,24 @@ cd web && npm run dev
|
|||||||
# Run backend (requires frontend built first)
|
# Run backend (requires frontend built first)
|
||||||
go run main.go
|
go run main.go
|
||||||
```
|
```
|
||||||
```
|
|
||||||
|
````
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add go.mod go.sum Makefile main.go README.md
|
git add go.mod go.sum Makefile main.go README.md
|
||||||
git commit -m "feat: initialize project structure and dependencies"
|
git commit -m "feat: initialize project structure and dependencies"
|
||||||
```
|
````
|
||||||
|
|
||||||
---
|
***
|
||||||
|
|
||||||
### Task 2: Create Internal Package Structure
|
### Task 2: Create Internal Package Structure
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `internal/config/config.go`
|
- Create: `internal/config/config.go`
|
||||||
- Create: `internal/models/models.go`
|
- Create: `internal/models/models.go`
|
||||||
|
|
||||||
- [ ] **Step 1: Create config package**
|
- [ ] **Step 1: Create config package**
|
||||||
|
|
||||||
Create `internal/config/config.go`:
|
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"
|
git commit -m "feat: add config and models packages"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
***
|
||||||
|
|
||||||
### Task 3: Implement Database Layer
|
### Task 3: Implement Database Layer
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `internal/database/database.go`
|
|
||||||
|
|
||||||
|
- Create: `internal/database/database.go`
|
||||||
- [ ] **Step 1: Create database package**
|
- [ ] **Step 1: Create database package**
|
||||||
|
|
||||||
Create `internal/database/database.go`:
|
Create `internal/database/database.go`:
|
||||||
@@ -655,14 +656,14 @@ git add internal/database/
|
|||||||
git commit -m "feat: implement SQLite database layer"
|
git commit -m "feat: implement SQLite database layer"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
***
|
||||||
|
|
||||||
### Task 4: Implement JWT Authentication
|
### Task 4: Implement JWT Authentication
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `internal/middleware/auth.go`
|
- Create: `internal/middleware/auth.go`
|
||||||
- Create: `internal/handler/auth.go`
|
- Create: `internal/handler/auth.go`
|
||||||
|
|
||||||
- [ ] **Step 1: Create auth middleware**
|
- [ ] **Step 1: Create auth middleware**
|
||||||
|
|
||||||
Create `internal/middleware/auth.go`:
|
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"
|
git commit -m "feat: implement JWT authentication middleware and handlers"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
***
|
||||||
|
|
||||||
## Phase 2: Gitea API Integration
|
## Phase 2: Gitea API Integration
|
||||||
|
|
||||||
### Task 5: Implement Gitea API Client
|
### Task 5: Implement Gitea API Client
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `internal/gitea/client.go`
|
- Create: `internal/gitea/client.go`
|
||||||
- Create: `internal/gitea/types.go`
|
- Create: `internal/gitea/types.go`
|
||||||
|
|
||||||
- [ ] **Step 1: Create Gitea types**
|
- [ ] **Step 1: Create Gitea types**
|
||||||
|
|
||||||
Create `internal/gitea/types.go`:
|
Create `internal/gitea/types.go`:
|
||||||
@@ -1145,14 +1146,14 @@ git add internal/gitea/
|
|||||||
git commit -m "feat: implement Gitea API client"
|
git commit -m "feat: implement Gitea API client"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
***
|
||||||
|
|
||||||
### Task 6: Implement Sync Engine
|
### Task 6: Implement Sync Engine
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `internal/sync/engine.go`
|
- Create: `internal/sync/engine.go`
|
||||||
- Create: `internal/sync/scheduler.go`
|
- Create: `internal/sync/scheduler.go`
|
||||||
|
|
||||||
- [ ] **Step 1: Create sync engine**
|
- [ ] **Step 1: Create sync engine**
|
||||||
|
|
||||||
Create `internal/sync/engine.go`:
|
Create `internal/sync/engine.go`:
|
||||||
@@ -1532,15 +1533,15 @@ git add internal/sync/
|
|||||||
git commit -m "feat: implement sync engine and scheduler"
|
git commit -m "feat: implement sync engine and scheduler"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
***
|
||||||
|
|
||||||
## Phase 3: HTTP Handlers
|
## Phase 3: HTTP Handlers
|
||||||
|
|
||||||
### Task 7: Implement Server Management Handlers
|
### Task 7: Implement Server Management Handlers
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `internal/handler/server.go`
|
|
||||||
|
|
||||||
|
- Create: `internal/handler/server.go`
|
||||||
- [ ] **Step 1: Create server handler**
|
- [ ] **Step 1: Create server handler**
|
||||||
|
|
||||||
Create `internal/handler/server.go`:
|
Create `internal/handler/server.go`:
|
||||||
@@ -1885,13 +1886,13 @@ git add internal/handler/server.go
|
|||||||
git commit -m "feat: implement server management handlers"
|
git commit -m "feat: implement server management handlers"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
***
|
||||||
|
|
||||||
### Task 8: Implement Log and Stats Handlers
|
### Task 8: Implement Log and Stats Handlers
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `internal/handler/log.go`
|
|
||||||
|
|
||||||
|
- Create: `internal/handler/log.go`
|
||||||
- [ ] **Step 1: Create log handler**
|
- [ ] **Step 1: Create log handler**
|
||||||
|
|
||||||
Create `internal/handler/log.go`:
|
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"
|
git commit -m "feat: implement sync log and stats handlers"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
***
|
||||||
|
|
||||||
### Task 9: Wire Up Main Server
|
### Task 9: Wire Up Main Server
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `main.go`
|
|
||||||
|
|
||||||
|
- Modify: `main.go`
|
||||||
- [ ] **Step 1: Update main.go with full server implementation**
|
- [ ] **Step 1: Update main.go with full server implementation**
|
||||||
|
|
||||||
Replace `main.go` with:
|
Replace `main.go` with:
|
||||||
@@ -2204,20 +2205,20 @@ git add main.go
|
|||||||
git commit -m "feat: wire up main server with all routes"
|
git commit -m "feat: wire up main server with all routes"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
***
|
||||||
|
|
||||||
## Phase 4: Frontend Implementation
|
## Phase 4: Frontend Implementation
|
||||||
|
|
||||||
### Task 10: Initialize Vue 3 Frontend
|
### Task 10: Initialize Vue 3 Frontend
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `web/package.json`
|
- Create: `web/package.json`
|
||||||
- Create: `web/vite.config.ts`
|
- Create: `web/vite.config.ts`
|
||||||
- Create: `web/tsconfig.json`
|
- Create: `web/tsconfig.json`
|
||||||
- Create: `web/index.html`
|
- Create: `web/index.html`
|
||||||
- Create: `web/src/main.ts`
|
- Create: `web/src/main.ts`
|
||||||
- Create: `web/src/App.vue`
|
- Create: `web/src/App.vue`
|
||||||
|
|
||||||
- [ ] **Step 1: Create web/package.json**
|
- [ ] **Step 1: Create web/package.json**
|
||||||
|
|
||||||
Create `web/package.json`:
|
Create `web/package.json`:
|
||||||
@@ -2478,15 +2479,15 @@ git add web/
|
|||||||
git commit -m "feat: initialize Vue 3 frontend structure"
|
git commit -m "feat: initialize Vue 3 frontend structure"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
***
|
||||||
|
|
||||||
### Task 11: Create API Client and Auth Store
|
### Task 11: Create API Client and Auth Store
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `web/src/api/index.ts`
|
- Create: `web/src/api/index.ts`
|
||||||
- Create: `web/src/api/types.ts`
|
- Create: `web/src/api/types.ts`
|
||||||
- Create: `web/src/stores/auth.ts`
|
- Create: `web/src/stores/auth.ts`
|
||||||
|
|
||||||
- [ ] **Step 1: Create API types**
|
- [ ] **Step 1: Create API types**
|
||||||
|
|
||||||
Create `web/src/api/types.ts`:
|
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"
|
git commit -m "feat: add API client and auth store"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
***
|
||||||
|
|
||||||
### Task 12: Create Login Page
|
### Task 12: Create Login Page
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `web/src/views/Login.vue`
|
|
||||||
|
|
||||||
|
- Create: `web/src/views/Login.vue`
|
||||||
- [ ] **Step 1: Create Login.vue**
|
- [ ] **Step 1: Create Login.vue**
|
||||||
|
|
||||||
Create `web/src/views/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"
|
git commit -m "feat: add login page"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
***
|
||||||
|
|
||||||
### Task 13: Create Layout Component
|
### Task 13: Create Layout Component
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `web/src/views/Layout.vue`
|
|
||||||
|
|
||||||
|
- Create: `web/src/views/Layout.vue`
|
||||||
- [ ] **Step 1: Create Layout.vue**
|
- [ ] **Step 1: Create Layout.vue**
|
||||||
|
|
||||||
Create `web/src/views/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"
|
git commit -m "feat: add layout component with navigation"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
***
|
||||||
|
|
||||||
### Task 14: Create Dashboard Page
|
### Task 14: Create Dashboard Page
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `web/src/views/Dashboard.vue`
|
|
||||||
|
|
||||||
|
- Create: `web/src/views/Dashboard.vue`
|
||||||
- [ ] **Step 1: Create Dashboard.vue**
|
- [ ] **Step 1: Create Dashboard.vue**
|
||||||
|
|
||||||
Create `web/src/views/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"
|
git commit -m "feat: add dashboard page"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
***
|
||||||
|
|
||||||
### Task 15: Create Servers Page
|
### Task 15: Create Servers Page
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `web/src/views/Servers.vue`
|
|
||||||
|
|
||||||
|
- Create: `web/src/views/Servers.vue`
|
||||||
- [ ] **Step 1: Create Servers.vue**
|
- [ ] **Step 1: Create Servers.vue**
|
||||||
|
|
||||||
Create `web/src/views/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"
|
git commit -m "feat: add servers management page"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
***
|
||||||
|
|
||||||
### Task 16: Create Repositories, Logs, and Settings Pages
|
### Task 16: Create Repositories, Logs, and Settings Pages
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `web/src/views/Repos.vue`
|
- Create: `web/src/views/Repos.vue`
|
||||||
- Create: `web/src/views/Logs.vue`
|
- Create: `web/src/views/Logs.vue`
|
||||||
- Create: `web/src/views/Settings.vue`
|
- Create: `web/src/views/Settings.vue`
|
||||||
|
|
||||||
- [ ] **Step 1: Create Repos.vue**
|
- [ ] **Step 1: Create Repos.vue**
|
||||||
|
|
||||||
Create `web/src/views/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"
|
git commit -m "feat: add repos, logs, and settings pages"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
***
|
||||||
|
|
||||||
## Phase 5: Frontend Build Integration
|
## Phase 5: Frontend Build Integration
|
||||||
|
|
||||||
### Task 17: Embed Frontend in Go Binary
|
### Task 17: Embed Frontend in Go Binary
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Modify: `main.go`
|
- Modify: `main.go`
|
||||||
- Modify: `Makefile`
|
- Modify: `Makefile`
|
||||||
|
|
||||||
- [ ] **Step 1: Add embed directive to main.go**
|
- [ ] **Step 1: Add embed directive to main.go**
|
||||||
|
|
||||||
Edit `main.go`, add after the imports:
|
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:
|
Edit `main.go` imports:
|
||||||
|
|
||||||
@@ -4055,16 +4056,16 @@ git add main.go Makefile
|
|||||||
git commit -m "feat: embed frontend in Go binary"
|
git commit -m "feat: embed frontend in Go binary"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
***
|
||||||
|
|
||||||
## Phase 6: Testing and Documentation
|
## Phase 6: Testing and Documentation
|
||||||
|
|
||||||
### Task 18: Create Tests
|
### Task 18: Create Tests
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `internal/database/database_test.go`
|
- Create: `internal/database/database_test.go`
|
||||||
- Create: `internal/gitea/client_test.go`
|
- Create: `internal/gitea/client_test.go`
|
||||||
|
|
||||||
- [ ] **Step 1: Create database tests**
|
- [ ] **Step 1: Create database tests**
|
||||||
|
|
||||||
Create `internal/database/database_test.go`:
|
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"
|
git commit -m "test: add unit tests for database and Gitea client"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
***
|
||||||
|
|
||||||
### Task 19: Final Documentation
|
### Task 19: Final Documentation
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Modify: `README.md`
|
- Modify: `README.md`
|
||||||
- Create: `.gitignore`
|
- Create: `.gitignore`
|
||||||
|
|
||||||
- [ ] **Step 1: Update README.md**
|
- [ ] **Step 1: Update README.md**
|
||||||
|
|
||||||
Replace `README.md` with:
|
Replace `README.md` with:
|
||||||
|
|
||||||
```markdown
|
````markdown
|
||||||
# GitM - Gitea Repository Sync Tool
|
# GitM - Gitea Repository Sync Tool
|
||||||
|
|
||||||
Cross-platform tool to synchronize all repositories from multiple Gitea servers to local storage.
|
Cross-platform tool to synchronize all repositories from multiple Gitea servers to local storage.
|
||||||
@@ -4246,7 +4247,7 @@ cd gitm
|
|||||||
make all
|
make all
|
||||||
|
|
||||||
# The binary will be at bin/gitm
|
# The binary will be at bin/gitm
|
||||||
```
|
````
|
||||||
|
|
||||||
### First Run
|
### First Run
|
||||||
|
|
||||||
@@ -4264,8 +4265,9 @@ make all
|
|||||||
### Access Web UI
|
### Access Web UI
|
||||||
|
|
||||||
Open your browser and navigate to:
|
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
|
## Usage
|
||||||
|
|
||||||
@@ -4302,6 +4304,7 @@ To discover new repositories without syncing, click **Discover**.
|
|||||||
### Storage
|
### Storage
|
||||||
|
|
||||||
Repositories are stored as bare git mirrors at:
|
Repositories are stored as bare git mirrors at:
|
||||||
|
|
||||||
```
|
```
|
||||||
<data_dir>/repos/server_<id>_<name>/<owner>/<repo>.git
|
<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:
|
The API is available at `/api/` and requires JWT authentication:
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
- `POST /api/login` - Authenticate and get token
|
- `POST /api/login` - Authenticate and get token
|
||||||
- `POST /api/init` - Initialize admin password
|
- `POST /api/init` - Initialize admin password
|
||||||
|
|
||||||
### Settings
|
### Settings
|
||||||
|
|
||||||
- `GET /api/settings` - Get all settings
|
- `GET /api/settings` - Get all settings
|
||||||
- `PUT /api/settings` - Update settings
|
- `PUT /api/settings` - Update settings
|
||||||
|
|
||||||
### Servers
|
### Servers
|
||||||
|
|
||||||
- `GET /api/servers` - List all servers
|
- `GET /api/servers` - List all servers
|
||||||
- `POST /api/servers` - Add a server
|
- `POST /api/servers` - Add a server
|
||||||
- `PUT /api/servers/:id` - Update 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
|
- `GET /api/servers/:id/sync/status` - Get sync status
|
||||||
|
|
||||||
### Sync
|
### Sync
|
||||||
|
|
||||||
- `POST /api/sync/all` - Sync all servers
|
- `POST /api/sync/all` - Sync all servers
|
||||||
- `GET /api/sync/logs` - Get sync logs
|
- `GET /api/sync/logs` - Get sync logs
|
||||||
- `GET /api/sync/stats` - Get statistics
|
- `GET /api/sync/stats` - Get statistics
|
||||||
@@ -4361,7 +4368,7 @@ npm install
|
|||||||
npm run dev
|
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
|
### Backend Development
|
||||||
|
|
||||||
@@ -4409,6 +4416,7 @@ gitm/
|
|||||||
## License
|
## License
|
||||||
|
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: Create .gitignore**
|
- [ ] **Step 2: Create .gitignore**
|
||||||
@@ -4416,62 +4424,71 @@ MIT License
|
|||||||
Create `.gitignore`:
|
Create `.gitignore`:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
# Binaries
|
# Binaries
|
||||||
|
|
||||||
bin/
|
bin/
|
||||||
gitm
|
gitm
|
||||||
gitm.exe
|
gitm.exe
|
||||||
gitm-linux
|
gitm-linux
|
||||||
|
|
||||||
# Data
|
# Data
|
||||||
|
|
||||||
data/
|
data/
|
||||||
*.db
|
\*.db
|
||||||
*.db-shm
|
\*.db-shm
|
||||||
*.db-wal
|
\*.db-wal
|
||||||
|
|
||||||
# Go
|
# Go
|
||||||
*.exe
|
|
||||||
*.exe~
|
\*.exe
|
||||||
*.dll
|
\*.exe\~
|
||||||
*.so
|
\*.dll
|
||||||
*.dylib
|
\*.so
|
||||||
*.test
|
\*.dylib
|
||||||
*.out
|
\*.test
|
||||||
|
\*.out
|
||||||
go.work
|
go.work
|
||||||
|
|
||||||
# Node
|
# Node
|
||||||
web/node_modules/
|
|
||||||
|
web/node\_modules/
|
||||||
web/dist/
|
web/dist/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
|
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.swp
|
\*.swp
|
||||||
*.swo
|
\*.swo
|
||||||
*~
|
\*\~
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
|
||||||
|
.DS\_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Temporary
|
# Temporary
|
||||||
*.log
|
|
||||||
*.tmp
|
\*.log
|
||||||
```
|
\*.tmp
|
||||||
|
|
||||||
|
````
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add README.md .gitignore
|
git add README.md .gitignore
|
||||||
git commit -m "docs: update README and add .gitignore"
|
git commit -m "docs: update README and add .gitignore"
|
||||||
```
|
````
|
||||||
|
|
||||||
---
|
***
|
||||||
|
|
||||||
## Task 20: Final Build Verification
|
## Task 20: Final Build Verification
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- None (verification task)
|
|
||||||
|
|
||||||
|
- None (verification task)
|
||||||
- [ ] **Step 1: Full clean build**
|
- [ ] **Step 1: Full clean build**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -4537,7 +4554,7 @@ git add -A
|
|||||||
git commit -m "chore: final build verification complete"
|
git commit -m "chore: final build verification complete"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
***
|
||||||
|
|
||||||
## Implementation Complete
|
## Implementation Complete
|
||||||
|
|
||||||
@@ -4566,3 +4583,4 @@ The GitM project is now fully implemented with:
|
|||||||
6. Add more detailed sync progress reporting
|
6. Add more detailed sync progress reporting
|
||||||
7. Implement backup/restore functionality
|
7. Implement backup/restore functionality
|
||||||
8. Add Docker support
|
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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"embed"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"log"
|
"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 (
|
var (
|
||||||
flagAddr = flag.String("addr", ":9000", "Listen address")
|
flagAddr = flag.String("addr", "", "Listen address")
|
||||||
flagDataDir = flag.String("data", "./data", "Data directory")
|
flagDataDir = flag.String("data", "", "Data directory")
|
||||||
flagInit = flag.Bool("init", false, "Initialize database and set password")
|
flagInit = flag.Bool("init", false, "Initialize database and set password")
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
flag.Parse()
|
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")
|
if listenAddr, err := database.GetSetting("listen_addr"); err == nil && listenAddr != "" {
|
||||||
fmt.Printf("Listen: %s\n", *flagAddr)
|
cfg.ListenAddr = listenAddr
|
||||||
fmt.Printf("Data: %s\n", *flagDataDir)
|
}
|
||||||
|
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 {
|
if *flagInit {
|
||||||
fmt.Println("Initialize mode: TODO")
|
runInitMode()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Fatal(runServer())
|
if pwd, _ := database.GetSetting("admin_password"); pwd == "" {
|
||||||
|
fmt.Println("Not initialized. Please run with --init flag first.")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 runServer() error {
|
func runInitMode() {
|
||||||
return fmt.Errorf("not implemented")
|
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