Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
S
Son Of Anton
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Administrator
Son Of Anton
Commits
dbe0dcba
Commit
dbe0dcba
authored
Mar 29, 2026
by
Mahmoud Aglan
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
gitlab try 1
parent
10eb3289
Changes
9
Show whitespace changes
Inline
Side-by-side
Showing
9 changed files
with
2072 additions
and
427 deletions
+2072
-427
FULL_CODEBASE.txt
FULL_CODEBASE.txt
+165
-147
main.py
backend/main.py
+17
-9
models.py
backend/models.py
+46
-0
gitlab_routes.py
backend/routes/gitlab_routes.py
+611
-0
gitlab_service.py
backend/services/gitlab_service.py
+494
-0
App.jsx
frontend/src/App.jsx
+5
-12
api.js
frontend/src/api.js
+88
-115
Sidebar.jsx
frontend/src/components/Sidebar.jsx
+106
-144
GitLabPage.jsx
frontend/src/pages/GitLabPage.jsx
+540
-0
No files found.
FULL_CODEBASE.txt
View file @
dbe0dcba
...
@@ -15,10 +15,10 @@
...
@@ -15,10 +15,10 @@
PROJECT CODEBASE — FULL SOURCE DUMP
PROJECT CODEBASE — FULL SOURCE DUMP
==============================================================================
==============================================================================
Generated: 2026-03-29 1
4:58:58
Generated: 2026-03-29 1
5:16:11
Source Dir: /Users/mahmoudaglan/son-of-anton
Source Dir: /Users/mahmoudaglan/son-of-anton
Total Files: 57
Total Files: 57
Total Lines: 13
183
Total Lines: 13
201
Total Size: 475KB
Total Size: 475KB
THIS FILE CONTAINS THE COMPLETE CODEBASE INCLUDING:
THIS FILE CONTAINS THE COMPLETE CODEBASE INCLUDING:
...
@@ -136,7 +136,7 @@ Collected file paths:
...
@@ -136,7 +136,7 @@ Collected file paths:
[016] 156 4KB backend/routes/attachments.py
[016] 156 4KB backend/routes/attachments.py
[017] 90 2KB backend/routes/auth_routes.py
[017] 90 2KB backend/routes/auth_routes.py
[018] 193 6KB backend/routes/chat_routes.py
[018] 193 6KB backend/routes/chat_routes.py
[019]
48
1KB backend/routes/files_routes.py
[019]
62
1KB backend/routes/files_routes.py
[020] 309 10KB backend/routes/knowledge_routes.py
[020] 309 10KB backend/routes/knowledge_routes.py
[021] 127 3KB backend/routes/messages_patch.py
[021] 127 3KB backend/routes/messages_patch.py
[022] 28 855B backend/seed.py
[022] 28 855B backend/seed.py
...
@@ -155,9 +155,9 @@ Collected file paths:
...
@@ -155,9 +155,9 @@ Collected file paths:
[035] 27 646B frontend/package.json
[035] 27 646B frontend/package.json
[036] 5 80B frontend/postcss.config.js
[036] 5 80B frontend/postcss.config.js
[037] 65 1KB frontend/src/App.jsx
[037] 65 1KB frontend/src/App.jsx
[038] 2
15
8KB frontend/src/api.js
[038] 2
21
8KB frontend/src/api.js
[039] 158 4KB frontend/src/components/AttachmentPreview.jsx
[039] 158 4KB frontend/src/components/AttachmentPreview.jsx
[040] 4
20
17KB frontend/src/components/ChatView.jsx
[040] 4
18
17KB frontend/src/components/ChatView.jsx
[041] 95 3KB frontend/src/components/CodeBlock.jsx
[041] 95 3KB frontend/src/components/CodeBlock.jsx
[042] 81 1KB frontend/src/components/FileUploadButton.jsx
[042] 81 1KB frontend/src/components/FileUploadButton.jsx
[043] 188 8KB frontend/src/components/MessageBubble.jsx
[043] 188 8KB frontend/src/components/MessageBubble.jsx
...
@@ -3779,7 +3779,7 @@ Collected file paths:
...
@@ -3779,7 +3779,7 @@ Collected file paths:
┌──────────────────────────────────────────────────────────────────────────────
┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [019/57]: backend/routes/files_routes.py
│ 📄 FILE [019/57]: backend/routes/files_routes.py
│ LANGUAGE: python | LINES:
48 | SIZE: 1238
bytes
│ LANGUAGE: python | LINES:
62 | SIZE: 1664
bytes
├──────────────────────────────────────────────────────────────────────────────
├──────────────────────────────────────────────────────────────────────────────
│
│
1 """
1 """
...
@@ -3787,50 +3787,64 @@ Collected file paths:
...
@@ -3787,50 +3787,64 @@ Collected file paths:
3 """
3 """
4
4
5 import io
5 import io
6 import
zipfil
e
6 import
r
e
7
from pydantic import BaseModel
7
import zipfile
8
8
from typing import Optional
9 from
fastapi import APIRouter
9 from
pydantic import BaseModel
10
from fastapi.responses import StreamingResponse
10
11
11
from fastapi import APIRouter
12 from
backend.services.code_extractor import extract_code_blocks
12 from
fastapi.responses import StreamingResponse
13
13
14
router = APIRouter()
14
from backend.services.code_extractor import extract_code_blocks
15
15
16
16
router = APIRouter()
17
class ExtractBody(BaseModel):
17
18
markdown: str
18
19
19
class ExtractBody(BaseModel):
20
20
markdown: str
21
@router.post("/extract")
21
title: Optional[str] = None
22
def extract_files(body: ExtractBody):
22
23
blocks = extract_code_blocks(body.markdown)
23
24
return {"files": blocks}
24
@router.post("/extract")
25
25
def extract_files(body: ExtractBody):
26
26
blocks = extract_code_blocks(body.markdown)
27
@router.post("/download-zip")
27
return {"files": blocks}
28
def download_zip(body: ExtractBody):
28
29
blocks = extract_code_blocks(body.markdown)
29
30
if not blocks:
30
@router.post("/download-zip")
31
return {"error": "No code blocks found"}
31
def download_zip(body: ExtractBody):
32
32
blocks = extract_code_blocks(body.markdown)
33
buf = io.BytesIO()
33
if not blocks:
34
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
34
return {"error": "No code blocks found"}
35
seen = set()
35
36
for b in blocks:
36
# Keep LAST occurrence of each filename (latest version wins)
37
name = b["filename"]
37
file_map: dict[str, str] = {}
38
if name in seen
:
38
for b in blocks
:
39
base, ext = name.rsplit(".", 1) if "." in name else (name, "txt")
39
file_map[b["filename"]] = b["code"]
40
name = f"{base}_{len(seen)}.{ext}"
40
41
seen.add(name)
41
if not file_map:
42
zf.writestr(name, b["code"])
42
return {"error": "No code blocks found"}
43
43
44 buf.seek(0)
44 # Build a safe zip filename from chat title
45 return StreamingResponse(
45 raw_title = (body.title or "").strip()
46 buf,
46 if not raw_title or raw_title == "New Chat":
47 media_type="application/zip",
47 safe_title = "code"
48 headers={"Content-Disposition": "attachment; filename=son-of-anton-code.zip"},
48 else:
49 )│
49 safe_title = re.sub(r'[^\w\s-]', '', raw_title).strip()
50 safe_title = re.sub(r'[\s]+', '-', safe_title)[:60] or "code"
51 zip_filename = f"{safe_title}.zip"
52
53 buf = io.BytesIO()
54 with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
55 for name, code in file_map.items():
56 zf.writestr(name, code)
57
58 buf.seek(0)
59 return StreamingResponse(
60 buf,
61 media_type="application/zip",
62 headers={"Content-Disposition": f'attachment; filename="{zip_filename}"'},
63 )│
└──────────────────────────────────────────────────────────────────────────────
└──────────────────────────────────────────────────────────────────────────────
✅ END OF [019]: backend/routes/files_routes.py
✅ END OF [019]: backend/routes/files_routes.py
...
@@ -10929,7 +10943,7 @@ Collected file paths:
...
@@ -10929,7 +10943,7 @@ Collected file paths:
┌──────────────────────────────────────────────────────────────────────────────
┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [038/57]: frontend/src/api.js
│ 📄 FILE [038/57]: frontend/src/api.js
│ LANGUAGE: javascript | LINES: 2
15 | SIZE: 8526
bytes
│ LANGUAGE: javascript | LINES: 2
21 | SIZE: 8820
bytes
├──────────────────────────────────────────────────────────────────────────────
├──────────────────────────────────────────────────────────────────────────────
│
│
1 const BASE = "/api";
1 const BASE = "/api";
...
@@ -11127,11 +11141,11 @@ Collected file paths:
...
@@ -11127,11 +11141,11 @@ Collected file paths:
193 // Code Download
193 // Code Download
194 // ═══════════════════════════════════════════════════
194 // ═══════════════════════════════════════════════════
195
195
196 export async function downloadZip(token, markdown) {
196 export async function downloadZip(token, markdown
, chatTitle
) {
197 const res = await fetch(`${BASE}/files/download-zip`, {
197 const res = await fetch(`${BASE}/files/download-zip`, {
198 method: "POST",
198 method: "POST",
199 headers: headers(token),
199 headers: headers(token),
200 body: JSON.stringify({ markdown }),
200 body: JSON.stringify({ markdown
, title: chatTitle || null
}),
201 });
201 });
202 if (!res.ok) throw new Error("Download failed");
202 if (!res.ok) throw new Error("Download failed");
203 const ct = res.headers.get("content-type") || "";
203 const ct = res.headers.get("content-type") || "";
...
@@ -11140,14 +11154,20 @@ Collected file paths:
...
@@ -11140,14 +11154,20 @@ Collected file paths:
206 const url = URL.createObjectURL(blob);
206 const url = URL.createObjectURL(blob);
207 const a = document.createElement("a");
207 const a = document.createElement("a");
208 a.href = url;
208 a.href = url;
209 a.download = "son-of-anton-code.zip";
209 // Derive filename from chat title, fallback to generic
210 a.click();
210 const raw = (chatTitle || "").trim();
211 URL.revokeObjectURL(url);
211 const safeName =
212 } else {
212 raw && raw !== "New Chat"
213 const data = await res.json();
213 ? raw.replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").slice(0, 60) || "code"
214 if (data.error) throw new Error(data.error);
214 : "code";
215 }
215 a.download = `${safeName}.zip`;
216 }│
216 a.click();
217 URL.revokeObjectURL(url);
218 } else {
219 const data = await res.json();
220 if (data.error) throw new Error(data.error);
221 }
222 }│
└──────────────────────────────────────────────────────────────────────────────
└──────────────────────────────────────────────────────────────────────────────
✅ END OF [038]: frontend/src/api.js
✅ END OF [038]: frontend/src/api.js
...
@@ -11322,7 +11342,7 @@ Collected file paths:
...
@@ -11322,7 +11342,7 @@ Collected file paths:
┌──────────────────────────────────────────────────────────────────────────────
┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [040/57]: frontend/src/components/ChatView.jsx
│ 📄 FILE [040/57]: frontend/src/components/ChatView.jsx
│ LANGUAGE: jsx | LINES: 4
20 | SIZE: 17903
bytes
│ LANGUAGE: jsx | LINES: 4
18 | SIZE: 17897
bytes
├──────────────────────────────────────────────────────────────────────────────
├──────────────────────────────────────────────────────────────────────────────
│
│
1 import React, { useState, useEffect, useRef, useCallback } from "react";
1 import React, { useState, useEffect, useRef, useCallback } from "react";
...
@@ -11660,92 +11680,90 @@ Collected file paths:
...
@@ -11660,92 +11680,90 @@ Collected file paths:
333 <div className="flex items-end gap-1.5">
333 <div className="flex items-end gap-1.5">
334 <button
334 <button
335 onClick={toggleSettings}
335 onClick={toggleSettings}
336 className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${
336 className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${
showSettings ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card active:bg-anton-card"
337
showSettings ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card active:bg-anton-card"
337
}`}
338
}`}
338
>
339 >
339
<Settings2 size={18} /
>
340
<Settings2 size={18} /
>
340
</button
>
341
</button>
341
342
342
<button
343
<button
343
onClick={() => fileRef.current?.click()}
344
onClick={() => fileRef.current?.click()}
344
className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${pendingFiles.length ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card active:bg-anton-card"
345
className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${
345
}`}
346
pendingFiles.length ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card active:bg-anton-card
"
346
title="Attach files
"
347
}`}
347
>
348
title="Attach files"
348
<Paperclip size={18} />
349 >
349
</button
>
350
<Paperclip size={18} />
350
351 <
/button>
351 <
input
352
352
ref={fileRef}
353
<input
353
type="file"
354
ref={fileRef}
354
multiple
355
type="file
"
355
className="hidden
"
356
multiple
356
accept="image/*,video/*,.pdf,.txt,.md,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.h,.go,.rs,.rb,.php,.html,.css,.json,.yaml,.yml,.xml,.toml,.csv,.sql,.sh,.swift,.kt,.lua,.gd,.dart,.vue,.svelte,.log"
357
className="hidden"
357
onChange={(e) => { addFiles(Array.from(e.target.files || [])); e.target.value = ""; }}
358
accept="image/*,video/*,.pdf,.txt,.md,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.h,.go,.rs,.rb,.php,.html,.css,.json,.yaml,.yml,.xml,.toml,.csv,.sql,.sh,.swift,.kt,.lua,.gd,.dart,.vue,.svelte,.log"
358
/>
359
onChange={(e) => { addFiles(Array.from(e.target.files || [])); e.target.value = ""; }}
359
360
/
>
360
<div className="flex-1 min-w-0"
>
361
361
<textarea
362
<div className="flex-1 min-w-0">
362
ref={inputRef}
363
<textarea
363
value={input}
364
ref={inputRef
}
364
onChange={(e) => setInput(e.target.value)
}
365
value={input
}
365
onKeyDown={handleKeyDown
}
366 on
Change={(e) => setInput(e.target.value)
}
366 on
Paste={handlePaste
}
367
onKeyDown={handleKeyDown
}
367
placeholder={pendingFiles.length ? "Add a message…" : "Ask anything…"
}
368
onPaste={handlePaste
}
368
rows={1
}
369
placeholder={pendingFiles.length ? "Add a message…" : "Ask anything…"
}
369
style={{ maxHeight: "120px" }
}
370
rows={1}
370
className="w-full bg-anton-card border border-anton-border rounded-xl px-3 py-2.5 text-white resize-none focus:outline-none focus:border-anton-accent transition leading-snug"
371
style={{ maxHeight: "120px" }}
371
onInput={(e) => {
372
className="w-full bg-anton-card border border-anton-border rounded-xl px-3 py-2.5 text-white resize-none focus:outline-none focus:border-anton-accent transition leading-snug"
372
e.target.style.height = "auto";
373
onInput={(e) => {
373
e.target.style.height = Math.min(e.target.scrollHeight, 120) + "px";
374
e.target.style.height = "auto";
374
}}
375
e.target.style.height = Math.min(e.target.scrollHeight, 120) + "px";
375
/>
376
}}
376
</div>
377
/>
377
378
</div>
378
{streaming ? (
379
379
<button
380
{streaming ? (
380
onClick={() => streamManager.abortStream(chatId)}
381
<button
381
className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center active:scale-95"
382
onClick={() => streamManager.abortStream(chatId)}
382
>
383
className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center active:scale-95"
383
<Square size={18} />
384 >
384
</button
>
385
<Square size={18} />
385
) : (
386 <
/button>
386 <
button
387
) : (
387
onClick={handleSend}
388
<button
388
disabled={(!input.trim() && !pendingFiles.length) || uploading}
389
onClick={handleSend}
389
className="p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center disabled:opacity-30 active:scale-95"
390
disabled={(!input.trim() && !pendingFiles.length) || uploading}
390
>
391
className="p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center disabled:opacity-30 active:scale-95"
391
{uploading ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
392 >
392
</button
>
393
{uploading ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />
}
393
)
}
394
</button
>
394
</div
>
395
)}
395
396
</div>
396
{/* Status bar */}
397
397
<div className="flex items-center gap-1.5 mt-1.5 text-[10px] text-anton-muted flex-wrap">
398
{/* Status bar */}
398
<span>{MODELS.find((m) => m.id === model)?.label}</span>
399
<div className="flex items-center gap-1.5 mt-1.5 text-[10px] text-anton-muted flex-wrap"
>
399
<span>•</span
>
400 <span>{
MODELS.find((m) => m.id === model)?.label}
</span>
400 <span>{
maxTokens.toLocaleString()} tok
</span>
401
<span>•</span>
401
{reasoningBudget > 0 && <><span>•</span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</span></>}
402
<span>{maxTokens.toLocaleString()} tok</span>
402
{selectedKbId && <><span>•</span><span className="text-green-400">📚 RAG</span></>}
403 {
reasoningBudget > 0 && <><span>•</span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()
}</span></>}
403 {
pendingFiles.length > 0 && <><span>•</span><span className="text-blue-400">📎 {pendingFiles.length
}</span></>}
404 {
selectedKbId && <><span>•</span><span className="text-green-400">📚 RAG</span></>}
404 {
messages.some((m) => m.role === "assistant") && (
405
{pendingFiles.length > 0 && <><span>•</span><span className="text-blue-400">📎 {pendingFiles.length}</span></>}
405
<button
406
{messages.some((m) => m.role === "assistant") && (
406
onClick={async () => {
407
<button
407
const all = messages.filter((m) => m.role === "assistant").map((m) => m.content).join("\n\n---\n\n");
408
onClick={async () => {
408
if (all) try { await downloadZip(state.token, all, currentChat?.title); } catch { /* */ }
409
const all = messages.filter((m) => m.role === "assistant").map((m) => m.content).join("\n\n---\n\n");
409
}}
410
if (all) try { await downloadZip(state.token, all); } catch { /* */ }
410
className="ml-auto hover:text-anton-accent transition"
411
}}
411
>
412
className="ml-auto hover:text-anton-accent transition"
412
⬇ Code
413 >
413
</button
>
414
⬇ Code
414
)}
415
</button
>
415
</div
>
416
)}
416
</div>
417 </div>
417 </div>
418 </div>
418 );
419 </div>
419 }│
420 );
421 }│
└──────────────────────────────────────────────────────────────────────────────
└──────────────────────────────────────────────────────────────────────────────
✅ END OF [040]: frontend/src/components/ChatView.jsx
✅ END OF [040]: frontend/src/components/ChatView.jsx
...
@@ -13979,9 +13997,9 @@ Collected file paths:
...
@@ -13979,9 +13997,9 @@ Collected file paths:
# ✅ END OF COMPLETE CODEBASE DUMP #
# ✅ END OF COMPLETE CODEBASE DUMP #
# #
# #
# Total Files: 57 #
# Total Files: 57 #
# Total Lines: 13
183
#
# Total Lines: 13
201
#
# Total Size: 475KB #
# Total Size: 475KB #
# Generated: 2026-03-29 1
4:58:58
#
# Generated: 2026-03-29 1
5:16:11
#
# #
# #
# This file contains EVERYTHING: source code, configs, env vars, Docker, #
# This file contains EVERYTHING: source code, configs, env vars, Docker, #
# CI/CD, build tools, package manifests, docs — the complete picture. #
# CI/CD, build tools, package manifests, docs — the complete picture. #
...
...
backend/main.py
View file @
dbe0dcba
...
@@ -20,9 +20,11 @@ from backend.routes.admin_routes import router as admin_router
...
@@ -20,9 +20,11 @@ from backend.routes.admin_routes import router as admin_router
from
backend.routes.knowledge_routes
import
router
as
knowledge_router
from
backend.routes.knowledge_routes
import
router
as
knowledge_router
from
backend.routes.files_routes
import
router
as
files_router
from
backend.routes.files_routes
import
router
as
files_router
from
backend.routes.attachment_routes
import
router
as
attachment_router
from
backend.routes.attachment_routes
import
router
as
attachment_router
from
backend.routes.gitlab_routes
import
router
as
gitlab_router
from
backend.services.bedrock_service
import
close_http_client
from
backend.services.bedrock_service
import
close_http_client
from
backend.services.gitlab_service
import
close_gitlab_client
APP_VERSION
=
"
2.1
.0"
APP_VERSION
=
"
3.0
.0"
APP_BUILD_TIME
=
str
(
int
(
time
.
time
()))
APP_BUILD_TIME
=
str
(
int
(
time
.
time
()))
...
@@ -49,6 +51,18 @@ def _run_migrations():
...
@@ -49,6 +51,18 @@ def _run_migrations():
ChatAttachment
.
__table__
.
create
(
bind
=
engine
,
checkfirst
=
True
)
ChatAttachment
.
__table__
.
create
(
bind
=
engine
,
checkfirst
=
True
)
print
(
" Created chat_attachments table"
)
print
(
" Created chat_attachments table"
)
# GitLab tables
for
table_name
in
(
"gitlab_configs"
,
"gitlab_operations"
,
"gitlab_audit_log"
):
if
table_name
not
in
existing_tables
:
from
backend.models
import
GitLabConfig
,
GitLabOperation
,
GitLabAuditLog
table_map
=
{
"gitlab_configs"
:
GitLabConfig
,
"gitlab_operations"
:
GitLabOperation
,
"gitlab_audit_log"
:
GitLabAuditLog
,
}
table_map
[
table_name
]
.
__table__
.
create
(
bind
=
engine
,
checkfirst
=
True
)
print
(
f
" Created {table_name} table"
)
except
Exception
as
e
:
except
Exception
as
e
:
print
(
f
" Migration note: {e}"
)
print
(
f
" Migration note: {e}"
)
...
@@ -61,6 +75,7 @@ async def lifespan(app: FastAPI):
...
@@ -61,6 +75,7 @@ async def lifespan(app: FastAPI):
print
(
f
"Son of Anton v{APP_VERSION} (build {APP_BUILD_TIME}) is online."
)
print
(
f
"Son of Anton v{APP_VERSION} (build {APP_BUILD_TIME}) is online."
)
yield
yield
await
close_http_client
()
await
close_http_client
()
await
close_gitlab_client
()
print
(
"Son of Anton shutting down."
)
print
(
"Son of Anton shutting down."
)
...
@@ -84,28 +99,21 @@ app.add_middleware(
...
@@ -84,28 +99,21 @@ app.add_middleware(
async
def
add_cache_headers
(
request
:
Request
,
call_next
):
async
def
add_cache_headers
(
request
:
Request
,
call_next
):
response
:
Response
=
await
call_next
(
request
)
response
:
Response
=
await
call_next
(
request
)
path
=
request
.
url
.
path
path
=
request
.
url
.
path
# API responses: never cache
if
path
.
startswith
(
"/api"
):
if
path
.
startswith
(
"/api"
):
response
.
headers
[
"Cache-Control"
]
=
"no-store, no-cache, must-revalidate, max-age=0"
response
.
headers
[
"Cache-Control"
]
=
"no-store, no-cache, must-revalidate, max-age=0"
response
.
headers
[
"Pragma"
]
=
"no-cache"
response
.
headers
[
"Pragma"
]
=
"no-cache"
response
.
headers
[
"Expires"
]
=
"0"
response
.
headers
[
"Expires"
]
=
"0"
# Hashed assets (contain hash in filename): cache aggressively
elif
path
.
startswith
(
"/assets/"
)
and
any
(
c
in
path
for
c
in
[
".js"
,
".css"
]):
elif
path
.
startswith
(
"/assets/"
)
and
any
(
c
in
path
for
c
in
[
".js"
,
".css"
]):
response
.
headers
[
"Cache-Control"
]
=
"public, max-age=31536000, immutable"
response
.
headers
[
"Cache-Control"
]
=
"public, max-age=31536000, immutable"
# HTML and everything else: never cache
elif
path
.
endswith
(
".html"
)
or
not
path
.
startswith
(
"/assets"
):
elif
path
.
endswith
(
".html"
)
or
not
path
.
startswith
(
"/assets"
):
response
.
headers
[
"Cache-Control"
]
=
"no-store, no-cache, must-revalidate, max-age=0"
response
.
headers
[
"Cache-Control"
]
=
"no-store, no-cache, must-revalidate, max-age=0"
response
.
headers
[
"Pragma"
]
=
"no-cache"
response
.
headers
[
"Pragma"
]
=
"no-cache"
response
.
headers
[
"Expires"
]
=
"0"
response
.
headers
[
"Expires"
]
=
"0"
# Always add version header for debugging
response
.
headers
[
"X-App-Version"
]
=
APP_VERSION
response
.
headers
[
"X-App-Version"
]
=
APP_VERSION
response
.
headers
[
"X-Build-Time"
]
=
APP_BUILD_TIME
response
.
headers
[
"X-Build-Time"
]
=
APP_BUILD_TIME
return
response
return
response
# Version endpoint for frontend to check
@
app
.
get
(
"/api/version"
)
@
app
.
get
(
"/api/version"
)
def
get_version
():
def
get_version
():
return
{
"version"
:
APP_VERSION
,
"build"
:
APP_BUILD_TIME
}
return
{
"version"
:
APP_VERSION
,
"build"
:
APP_BUILD_TIME
}
...
@@ -117,6 +125,7 @@ app.include_router(admin_router, prefix="/api/admin", tags=["Admin"])
...
@@ -117,6 +125,7 @@ app.include_router(admin_router, prefix="/api/admin", tags=["Admin"])
app
.
include_router
(
knowledge_router
,
prefix
=
"/api/knowledge"
,
tags
=
[
"Knowledge"
])
app
.
include_router
(
knowledge_router
,
prefix
=
"/api/knowledge"
,
tags
=
[
"Knowledge"
])
app
.
include_router
(
files_router
,
prefix
=
"/api/files"
,
tags
=
[
"Files"
])
app
.
include_router
(
files_router
,
prefix
=
"/api/files"
,
tags
=
[
"Files"
])
app
.
include_router
(
attachment_router
,
prefix
=
"/api"
,
tags
=
[
"Attachments"
])
app
.
include_router
(
attachment_router
,
prefix
=
"/api"
,
tags
=
[
"Attachments"
])
app
.
include_router
(
gitlab_router
,
prefix
=
"/api/gitlab"
,
tags
=
[
"GitLab"
])
FRONTEND_DIR
=
Path
(
__file__
)
.
parent
.
parent
/
"frontend"
/
"dist"
FRONTEND_DIR
=
Path
(
__file__
)
.
parent
.
parent
/
"frontend"
/
"dist"
...
@@ -135,7 +144,6 @@ async def serve_frontend(full_path: str):
...
@@ -135,7 +144,6 @@ async def serve_frontend(full_path: str):
file_path
=
FRONTEND_DIR
/
full_path
file_path
=
FRONTEND_DIR
/
full_path
if
full_path
and
file_path
.
is_file
():
if
full_path
and
file_path
.
is_file
():
resp
=
FileResponse
(
str
(
file_path
))
resp
=
FileResponse
(
str
(
file_path
))
# Don't cache non-hashed static files
resp
.
headers
[
"Cache-Control"
]
=
"no-store, no-cache, must-revalidate, max-age=0"
resp
.
headers
[
"Cache-Control"
]
=
"no-store, no-cache, must-revalidate, max-age=0"
return
resp
return
resp
index
=
FRONTEND_DIR
/
"index.html"
index
=
FRONTEND_DIR
/
"index.html"
...
...
backend/models.py
View file @
dbe0dcba
...
@@ -124,3 +124,49 @@ class KnowledgeDocument(Base):
...
@@ -124,3 +124,49 @@ class KnowledgeDocument(Base):
file_size
=
Column
(
Integer
,
default
=
0
)
file_size
=
Column
(
Integer
,
default
=
0
)
chunk_count
=
Column
(
Integer
,
default
=
0
)
chunk_count
=
Column
(
Integer
,
default
=
0
)
created_at
=
Column
(
DateTime
,
default
=
datetime
.
utcnow
)
created_at
=
Column
(
DateTime
,
default
=
datetime
.
utcnow
)
# ══════════════════════════════════════════════════════
# GitLab CE Integration Models
# ══════════════════════════════════════════════════════
class
GitLabConfig
(
Base
):
__tablename__
=
"gitlab_configs"
id
=
Column
(
String
(
36
),
primary_key
=
True
,
default
=
new_id
)
gitlab_url
=
Column
(
String
(
500
),
nullable
=
False
)
access_token_enc
=
Column
(
String
(
500
),
nullable
=
False
)
default_namespace
=
Column
(
String
(
200
),
nullable
=
True
)
is_active
=
Column
(
Boolean
,
default
=
True
)
created_at
=
Column
(
DateTime
,
default
=
datetime
.
utcnow
)
updated_at
=
Column
(
DateTime
,
default
=
datetime
.
utcnow
,
onupdate
=
datetime
.
utcnow
)
class
GitLabOperation
(
Base
):
__tablename__
=
"gitlab_operations"
id
=
Column
(
String
(
36
),
primary_key
=
True
,
default
=
new_id
)
operation_type
=
Column
(
String
(
50
),
nullable
=
False
)
status
=
Column
(
String
(
20
),
default
=
"pending"
)
project_id
=
Column
(
Integer
,
nullable
=
True
)
project_name
=
Column
(
String
(
200
),
nullable
=
True
)
branch
=
Column
(
String
(
200
),
nullable
=
True
)
payload
=
Column
(
Text
,
nullable
=
False
)
result
=
Column
(
Text
,
nullable
=
True
)
chat_id
=
Column
(
String
(
36
),
nullable
=
True
)
message_id
=
Column
(
String
(
36
),
nullable
=
True
)
created_by
=
Column
(
String
(
36
),
ForeignKey
(
"users.id"
),
nullable
=
False
)
approved_by
=
Column
(
String
(
36
),
nullable
=
True
)
created_at
=
Column
(
DateTime
,
default
=
datetime
.
utcnow
)
executed_at
=
Column
(
DateTime
,
nullable
=
True
)
class
GitLabAuditLog
(
Base
):
__tablename__
=
"gitlab_audit_log"
id
=
Column
(
String
(
36
),
primary_key
=
True
,
default
=
new_id
)
operation_id
=
Column
(
String
(
36
),
nullable
=
True
)
action
=
Column
(
String
(
100
),
nullable
=
False
)
details
=
Column
(
Text
,
nullable
=
True
)
user_id
=
Column
(
String
(
36
),
nullable
=
True
)
created_at
=
Column
(
DateTime
,
default
=
datetime
.
utcnow
)
\ No newline at end of file
backend/routes/gitlab_routes.py
0 → 100644
View file @
dbe0dcba
"""
GitLab CE integration routes — superadmin only.
Provides repo management, surgical code operations, approval queue, and audit log.
"""
import
json
from
datetime
import
datetime
from
typing
import
Optional
from
pydantic
import
BaseModel
from
fastapi
import
APIRouter
,
Depends
,
HTTPException
from
sqlalchemy.orm
import
Session
from
backend.database
import
get_db
from
backend.models
import
User
,
GitLabConfig
,
GitLabOperation
,
GitLabAuditLog
,
new_id
from
backend.auth
import
require_superadmin
from
backend.services
import
gitlab_service
router
=
APIRouter
()
# ══════════════════════════════════════════════════════
# Pydantic Bodies
# ══════════════════════════════════════════════════════
class
SaveConfigBody
(
BaseModel
):
gitlab_url
:
str
access_token
:
str
default_namespace
:
Optional
[
str
]
=
None
class
CreateProjectBody
(
BaseModel
):
name
:
str
description
:
str
=
""
visibility
:
str
=
"private"
namespace_id
:
Optional
[
int
]
=
None
initialize_with_readme
:
bool
=
True
class
FileOperationBody
(
BaseModel
):
file_path
:
str
content
:
str
commit_message
:
str
branch
:
str
=
"main"
class
BatchCommitBody
(
BaseModel
):
project_id
:
int
branch
:
str
=
"main"
commit_message
:
str
files
:
list
[
dict
]
# [{"file_path": "...", "content": "..."}]
class
CreateBranchBody
(
BaseModel
):
branch_name
:
str
ref
:
str
=
"main"
class
CreateMRBody
(
BaseModel
):
source_branch
:
str
target_branch
:
str
=
"main"
title
:
str
description
:
str
=
""
class
QueueOperationBody
(
BaseModel
):
operation_type
:
str
project_id
:
Optional
[
int
]
=
None
project_name
:
Optional
[
str
]
=
None
branch
:
Optional
[
str
]
=
None
payload
:
dict
chat_id
:
Optional
[
str
]
=
None
message_id
:
Optional
[
str
]
=
None
def
_get_config
(
db
:
Session
)
->
GitLabConfig
:
cfg
=
db
.
query
(
GitLabConfig
)
.
filter
(
GitLabConfig
.
is_active
==
True
)
.
first
()
if
not
cfg
:
raise
HTTPException
(
404
,
"GitLab not configured. Go to GitLab settings first."
)
return
cfg
def
_log_audit
(
db
:
Session
,
action
:
str
,
user_id
:
str
,
details
:
str
=
None
,
op_id
:
str
=
None
):
entry
=
GitLabAuditLog
(
operation_id
=
op_id
,
action
=
action
,
details
=
details
,
user_id
=
user_id
,
)
db
.
add
(
entry
)
db
.
commit
()
# ══════════════════════════════════════════════════════
# Configuration
# ══════════════════════════════════════════════════════
@
router
.
get
(
"/config"
)
def
get_config
(
admin
:
User
=
Depends
(
require_superadmin
),
db
:
Session
=
Depends
(
get_db
)):
cfg
=
db
.
query
(
GitLabConfig
)
.
filter
(
GitLabConfig
.
is_active
==
True
)
.
first
()
if
not
cfg
:
return
{
"configured"
:
False
}
return
{
"configured"
:
True
,
"id"
:
cfg
.
id
,
"gitlab_url"
:
cfg
.
gitlab_url
,
"token_masked"
:
gitlab_service
.
mask_token
(
cfg
.
access_token_enc
),
"default_namespace"
:
cfg
.
default_namespace
,
"updated_at"
:
str
(
cfg
.
updated_at
),
}
@
router
.
post
(
"/config"
)
async
def
save_config
(
body
:
SaveConfigBody
,
admin
:
User
=
Depends
(
require_superadmin
),
db
:
Session
=
Depends
(
get_db
),
):
result
=
await
gitlab_service
.
test_connection
(
body
.
gitlab_url
,
body
.
access_token
)
if
not
result
.
get
(
"ok"
):
raise
HTTPException
(
400
,
f
"Connection failed: {result.get('error', 'Unknown error')}"
)
existing
=
db
.
query
(
GitLabConfig
)
.
filter
(
GitLabConfig
.
is_active
==
True
)
.
first
()
if
existing
:
existing
.
gitlab_url
=
body
.
gitlab_url
.
rstrip
(
"/"
)
existing
.
access_token_enc
=
gitlab_service
.
encode_token
(
body
.
access_token
)
existing
.
default_namespace
=
body
.
default_namespace
existing
.
updated_at
=
datetime
.
utcnow
()
else
:
cfg
=
GitLabConfig
(
gitlab_url
=
body
.
gitlab_url
.
rstrip
(
"/"
),
access_token_enc
=
gitlab_service
.
encode_token
(
body
.
access_token
),
default_namespace
=
body
.
default_namespace
,
)
db
.
add
(
cfg
)
db
.
commit
()
_log_audit
(
db
,
"config_saved"
,
admin
.
id
,
f
"Connected to {body.gitlab_url} as {result.get('user')}"
)
return
{
"ok"
:
True
,
"user"
:
result
.
get
(
"user"
),
"name"
:
result
.
get
(
"name"
)}
@
router
.
post
(
"/test-connection"
)
async
def
test_connection
(
body
:
SaveConfigBody
,
admin
:
User
=
Depends
(
require_superadmin
),
):
result
=
await
gitlab_service
.
test_connection
(
body
.
gitlab_url
,
body
.
access_token
)
return
result
# ══════════════════════════════════════════════════════
# Projects
# ══════════════════════════════════════════════════════
@
router
.
get
(
"/projects"
)
async
def
list_projects
(
search
:
str
=
""
,
page
:
int
=
1
,
admin
:
User
=
Depends
(
require_superadmin
),
db
:
Session
=
Depends
(
get_db
),
):
cfg
=
_get_config
(
db
)
token
=
gitlab_service
.
decode_token
(
cfg
.
access_token_enc
)
return
await
gitlab_service
.
list_projects
(
cfg
.
gitlab_url
,
token
,
search
=
search
,
page
=
page
)
@
router
.
post
(
"/projects"
)
async
def
create_project
(
body
:
CreateProjectBody
,
admin
:
User
=
Depends
(
require_superadmin
),
db
:
Session
=
Depends
(
get_db
),
):
cfg
=
_get_config
(
db
)
token
=
gitlab_service
.
decode_token
(
cfg
.
access_token_enc
)
result
=
await
gitlab_service
.
create_project
(
cfg
.
gitlab_url
,
token
,
body
.
name
,
body
.
description
,
body
.
visibility
,
body
.
namespace_id
,
body
.
initialize_with_readme
,
)
_log_audit
(
db
,
"project_created"
,
admin
.
id
,
f
"Created project: {body.name} (id={result.get('id')})"
)
return
result
@
router
.
delete
(
"/projects/{project_id}"
)
async
def
delete_project
(
project_id
:
int
,
admin
:
User
=
Depends
(
require_superadmin
),
db
:
Session
=
Depends
(
get_db
),
):
cfg
=
_get_config
(
db
)
token
=
gitlab_service
.
decode_token
(
cfg
.
access_token_enc
)
ok
=
await
gitlab_service
.
delete_project
(
cfg
.
gitlab_url
,
token
,
project_id
)
if
not
ok
:
raise
HTTPException
(
500
,
"Failed to delete project"
)
_log_audit
(
db
,
"project_deleted"
,
admin
.
id
,
f
"Deleted project id={project_id}"
)
return
{
"ok"
:
True
}
@
router
.
get
(
"/projects/{project_id}"
)
async
def
get_project
(
project_id
:
int
,
admin
:
User
=
Depends
(
require_superadmin
),
db
:
Session
=
Depends
(
get_db
),
):
cfg
=
_get_config
(
db
)
token
=
gitlab_service
.
decode_token
(
cfg
.
access_token_enc
)
return
await
gitlab_service
.
get_project
(
cfg
.
gitlab_url
,
token
,
project_id
)
# ══════════════════════════════════════════════════════
# Branches
# ══════════════════════════════════════════════════════
@
router
.
get
(
"/projects/{project_id}/branches"
)
async
def
list_branches
(
project_id
:
int
,
admin
:
User
=
Depends
(
require_superadmin
),
db
:
Session
=
Depends
(
get_db
),
):
cfg
=
_get_config
(
db
)
token
=
gitlab_service
.
decode_token
(
cfg
.
access_token_enc
)
return
await
gitlab_service
.
list_branches
(
cfg
.
gitlab_url
,
token
,
project_id
)
@
router
.
post
(
"/projects/{project_id}/branches"
)
async
def
create_branch
(
project_id
:
int
,
body
:
CreateBranchBody
,
admin
:
User
=
Depends
(
require_superadmin
),
db
:
Session
=
Depends
(
get_db
),
):
cfg
=
_get_config
(
db
)
token
=
gitlab_service
.
decode_token
(
cfg
.
access_token_enc
)
result
=
await
gitlab_service
.
create_branch
(
cfg
.
gitlab_url
,
token
,
project_id
,
body
.
branch_name
,
body
.
ref
)
_log_audit
(
db
,
"branch_created"
,
admin
.
id
,
f
"Project {project_id}: created branch '{body.branch_name}' from '{body.ref}'"
)
return
result
# ══════════════════════════════════════════════════════
# File Tree & Files
# ══════════════════════════════════════════════════════
@
router
.
get
(
"/projects/{project_id}/tree"
)
async
def
get_tree
(
project_id
:
int
,
path
:
str
=
""
,
ref
:
str
=
"main"
,
recursive
:
bool
=
False
,
admin
:
User
=
Depends
(
require_superadmin
),
db
:
Session
=
Depends
(
get_db
),
):
cfg
=
_get_config
(
db
)
token
=
gitlab_service
.
decode_token
(
cfg
.
access_token_enc
)
return
await
gitlab_service
.
get_tree
(
cfg
.
gitlab_url
,
token
,
project_id
,
path
,
ref
,
recursive
)
@
router
.
get
(
"/projects/{project_id}/files"
)
async
def
get_file
(
project_id
:
int
,
file_path
:
str
,
ref
:
str
=
"main"
,
admin
:
User
=
Depends
(
require_superadmin
),
db
:
Session
=
Depends
(
get_db
),
):
cfg
=
_get_config
(
db
)
token
=
gitlab_service
.
decode_token
(
cfg
.
access_token_enc
)
return
await
gitlab_service
.
get_file
(
cfg
.
gitlab_url
,
token
,
project_id
,
file_path
,
ref
)
# ══════════════════════════════════════════════════════
# Merge Requests
# ══════════════════════════════════════════════════════
@
router
.
get
(
"/projects/{project_id}/merge-requests"
)
async
def
list_merge_requests
(
project_id
:
int
,
state
:
str
=
"opened"
,
admin
:
User
=
Depends
(
require_superadmin
),
db
:
Session
=
Depends
(
get_db
),
):
cfg
=
_get_config
(
db
)
token
=
gitlab_service
.
decode_token
(
cfg
.
access_token_enc
)
return
await
gitlab_service
.
list_merge_requests
(
cfg
.
gitlab_url
,
token
,
project_id
,
state
)
@
router
.
post
(
"/projects/{project_id}/merge-requests"
)
async
def
create_merge_request
(
project_id
:
int
,
body
:
CreateMRBody
,
admin
:
User
=
Depends
(
require_superadmin
),
db
:
Session
=
Depends
(
get_db
),
):
cfg
=
_get_config
(
db
)
token
=
gitlab_service
.
decode_token
(
cfg
.
access_token_enc
)
result
=
await
gitlab_service
.
create_merge_request
(
cfg
.
gitlab_url
,
token
,
project_id
,
body
.
source_branch
,
body
.
target_branch
,
body
.
title
,
body
.
description
,
)
_log_audit
(
db
,
"mr_created"
,
admin
.
id
,
f
"Project {project_id}: MR '{body.title}' ({body.source_branch} → {body.target_branch})"
)
return
result
# ══════════════════════════════════════════════════════
# Pipelines
# ══════════════════════════════════════════════════════
@
router
.
get
(
"/projects/{project_id}/pipelines"
)
async
def
list_pipelines
(
project_id
:
int
,
admin
:
User
=
Depends
(
require_superadmin
),
db
:
Session
=
Depends
(
get_db
),
):
cfg
=
_get_config
(
db
)
token
=
gitlab_service
.
decode_token
(
cfg
.
access_token_enc
)
return
await
gitlab_service
.
list_pipelines
(
cfg
.
gitlab_url
,
token
,
project_id
)
@
router
.
get
(
"/projects/{project_id}/pipelines/{pipeline_id}"
)
async
def
get_pipeline
(
project_id
:
int
,
pipeline_id
:
int
,
admin
:
User
=
Depends
(
require_superadmin
),
db
:
Session
=
Depends
(
get_db
),
):
cfg
=
_get_config
(
db
)
token
=
gitlab_service
.
decode_token
(
cfg
.
access_token_enc
)
pipeline
=
await
gitlab_service
.
get_pipeline
(
cfg
.
gitlab_url
,
token
,
project_id
,
pipeline_id
)
jobs
=
await
gitlab_service
.
get_pipeline_jobs
(
cfg
.
gitlab_url
,
token
,
project_id
,
pipeline_id
)
return
{
"pipeline"
:
pipeline
,
"jobs"
:
jobs
}
@
router
.
post
(
"/projects/{project_id}/pipelines/trigger"
)
async
def
trigger_pipeline
(
project_id
:
int
,
ref
:
str
=
"main"
,
admin
:
User
=
Depends
(
require_superadmin
),
db
:
Session
=
Depends
(
get_db
),
):
cfg
=
_get_config
(
db
)
token
=
gitlab_service
.
decode_token
(
cfg
.
access_token_enc
)
result
=
await
gitlab_service
.
trigger_pipeline
(
cfg
.
gitlab_url
,
token
,
project_id
,
ref
)
_log_audit
(
db
,
"pipeline_triggered"
,
admin
.
id
,
f
"Project {project_id}: triggered pipeline on '{ref}'"
)
return
result
# ══════════════════════════════════════════════════════
# Operation Queue (THE APPROVAL SYSTEM)
# ══════════════════════════════════════════════════════
@
router
.
post
(
"/operations"
)
def
queue_operation
(
body
:
QueueOperationBody
,
admin
:
User
=
Depends
(
require_superadmin
),
db
:
Session
=
Depends
(
get_db
),
):
op
=
GitLabOperation
(
operation_type
=
body
.
operation_type
,
project_id
=
body
.
project_id
,
project_name
=
body
.
project_name
,
branch
=
body
.
branch
,
payload
=
json
.
dumps
(
body
.
payload
),
chat_id
=
body
.
chat_id
,
message_id
=
body
.
message_id
,
created_by
=
admin
.
id
,
)
db
.
add
(
op
)
db
.
commit
()
db
.
refresh
(
op
)
_log_audit
(
db
,
"operation_queued"
,
admin
.
id
,
f
"{body.operation_type} queued (id={op.id})"
,
op
.
id
)
return
_op_dict
(
op
)
@
router
.
get
(
"/operations"
)
def
list_operations
(
status
:
str
=
"pending"
,
admin
:
User
=
Depends
(
require_superadmin
),
db
:
Session
=
Depends
(
get_db
),
):
q
=
db
.
query
(
GitLabOperation
)
if
status
!=
"all"
:
q
=
q
.
filter
(
GitLabOperation
.
status
==
status
)
ops
=
q
.
order_by
(
GitLabOperation
.
created_at
.
desc
())
.
limit
(
100
)
.
all
()
return
[
_op_dict
(
o
)
for
o
in
ops
]
@
router
.
post
(
"/operations/{op_id}/approve"
)
async
def
approve_operation
(
op_id
:
str
,
admin
:
User
=
Depends
(
require_superadmin
),
db
:
Session
=
Depends
(
get_db
),
):
op
=
db
.
query
(
GitLabOperation
)
.
filter
(
GitLabOperation
.
id
==
op_id
)
.
first
()
if
not
op
:
raise
HTTPException
(
404
,
"Operation not found"
)
if
op
.
status
!=
"pending"
:
raise
HTTPException
(
400
,
f
"Operation is {op.status}, not pending"
)
cfg
=
_get_config
(
db
)
token
=
gitlab_service
.
decode_token
(
cfg
.
access_token_enc
)
payload
=
json
.
loads
(
op
.
payload
)
try
:
result
=
await
_execute_operation
(
cfg
.
gitlab_url
,
token
,
op
.
operation_type
,
op
.
project_id
,
op
.
branch
,
payload
)
op
.
status
=
"executed"
op
.
result
=
json
.
dumps
(
result
)
if
isinstance
(
result
,
(
dict
,
list
))
else
str
(
result
)
op
.
executed_at
=
datetime
.
utcnow
()
op
.
approved_by
=
admin
.
id
db
.
commit
()
_log_audit
(
db
,
"operation_approved_executed"
,
admin
.
id
,
f
"{op.operation_type} executed successfully"
,
op
.
id
)
return
{
"ok"
:
True
,
"result"
:
result
}
except
Exception
as
e
:
op
.
status
=
"failed"
op
.
result
=
str
(
e
)
op
.
executed_at
=
datetime
.
utcnow
()
db
.
commit
()
_log_audit
(
db
,
"operation_failed"
,
admin
.
id
,
f
"{op.operation_type} failed: {str(e)}"
,
op
.
id
)
raise
HTTPException
(
500
,
f
"Execution failed: {str(e)}"
)
@
router
.
post
(
"/operations/{op_id}/reject"
)
def
reject_operation
(
op_id
:
str
,
admin
:
User
=
Depends
(
require_superadmin
),
db
:
Session
=
Depends
(
get_db
),
):
op
=
db
.
query
(
GitLabOperation
)
.
filter
(
GitLabOperation
.
id
==
op_id
)
.
first
()
if
not
op
:
raise
HTTPException
(
404
)
if
op
.
status
!=
"pending"
:
raise
HTTPException
(
400
,
f
"Operation is {op.status}"
)
op
.
status
=
"rejected"
op
.
approved_by
=
admin
.
id
op
.
executed_at
=
datetime
.
utcnow
()
db
.
commit
()
_log_audit
(
db
,
"operation_rejected"
,
admin
.
id
,
f
"{op.operation_type} rejected"
,
op
.
id
)
return
{
"ok"
:
True
}
@
router
.
delete
(
"/operations/{op_id}"
)
def
delete_operation
(
op_id
:
str
,
admin
:
User
=
Depends
(
require_superadmin
),
db
:
Session
=
Depends
(
get_db
),
):
op
=
db
.
query
(
GitLabOperation
)
.
filter
(
GitLabOperation
.
id
==
op_id
)
.
first
()
if
not
op
:
raise
HTTPException
(
404
)
db
.
delete
(
op
)
db
.
commit
()
return
{
"ok"
:
True
}
# ══════════════════════════════════════════════════════
# Direct Execute (bypass queue — for quick actions)
# ══════════════════════════════════════════════════════
@
router
.
post
(
"/execute/commit"
)
async
def
direct_commit
(
body
:
BatchCommitBody
,
admin
:
User
=
Depends
(
require_superadmin
),
db
:
Session
=
Depends
(
get_db
),
):
"""Direct batch commit without queuing — for when you want instant execution."""
cfg
=
_get_config
(
db
)
token
=
gitlab_service
.
decode_token
(
cfg
.
access_token_enc
)
result
=
await
gitlab_service
.
smart_batch_commit
(
cfg
.
gitlab_url
,
token
,
body
.
project_id
,
body
.
branch
,
body
.
commit_message
,
body
.
files
,
)
_log_audit
(
db
,
"direct_commit"
,
admin
.
id
,
f
"Project {body.project_id}, branch '{body.branch}': {len(body.files)} files committed"
,
)
return
result
@
router
.
post
(
"/execute/file"
)
async
def
direct_file_op
(
body
:
dict
,
admin
:
User
=
Depends
(
require_superadmin
),
db
:
Session
=
Depends
(
get_db
),
):
"""Direct single file create/update."""
cfg
=
_get_config
(
db
)
token
=
gitlab_service
.
decode_token
(
cfg
.
access_token_enc
)
project_id
=
body
[
"project_id"
]
file_path
=
body
[
"file_path"
]
content
=
body
[
"content"
]
commit_message
=
body
.
get
(
"commit_message"
,
f
"Update {file_path}"
)
branch
=
body
.
get
(
"branch"
,
"main"
)
create
=
body
.
get
(
"create"
,
False
)
result
=
await
gitlab_service
.
create_or_update_file
(
cfg
.
gitlab_url
,
token
,
project_id
,
file_path
,
content
,
commit_message
,
branch
,
create
,
)
action
=
"created"
if
create
else
"updated"
_log_audit
(
db
,
f
"file_{action}"
,
admin
.
id
,
f
"Project {project_id}: {action} {file_path} on {branch}"
)
return
result
# ══════════════════════════════════════════════════════
# Audit Log
# ══════════════════════════════════════════════════════
@
router
.
get
(
"/audit-log"
)
def
get_audit_log
(
page
:
int
=
1
,
per_page
:
int
=
50
,
admin
:
User
=
Depends
(
require_superadmin
),
db
:
Session
=
Depends
(
get_db
),
):
total
=
db
.
query
(
GitLabAuditLog
)
.
count
()
entries
=
(
db
.
query
(
GitLabAuditLog
)
.
order_by
(
GitLabAuditLog
.
created_at
.
desc
())
.
offset
((
page
-
1
)
*
per_page
)
.
limit
(
per_page
)
.
all
()
)
return
{
"total"
:
total
,
"page"
:
page
,
"entries"
:
[
{
"id"
:
e
.
id
,
"operation_id"
:
e
.
operation_id
,
"action"
:
e
.
action
,
"details"
:
e
.
details
,
"user_id"
:
e
.
user_id
,
"created_at"
:
str
(
e
.
created_at
),
}
for
e
in
entries
],
}
# ══════════════════════════════════════════════════════
# Namespaces
# ══════════════════════════════════════════════════════
@
router
.
get
(
"/namespaces"
)
async
def
list_namespaces
(
admin
:
User
=
Depends
(
require_superadmin
),
db
:
Session
=
Depends
(
get_db
),
):
cfg
=
_get_config
(
db
)
token
=
gitlab_service
.
decode_token
(
cfg
.
access_token_enc
)
return
await
gitlab_service
.
list_namespaces
(
cfg
.
gitlab_url
,
token
)
# ══════════════════════════════════════════════════════
# Helpers
# ══════════════════════════════════════════════════════
async
def
_execute_operation
(
gitlab_url
,
token
,
op_type
,
project_id
,
branch
,
payload
):
if
op_type
==
"batch_commit"
:
return
await
gitlab_service
.
smart_batch_commit
(
gitlab_url
,
token
,
project_id
,
branch
or
"main"
,
payload
.
get
(
"commit_message"
,
"Son of Anton commit"
),
payload
.
get
(
"files"
,
[]),
)
elif
op_type
==
"create_file"
:
return
await
gitlab_service
.
create_or_update_file
(
gitlab_url
,
token
,
project_id
,
payload
[
"file_path"
],
payload
[
"content"
],
payload
.
get
(
"commit_message"
,
f
"Create {payload['file_path']}"
),
branch
or
"main"
,
create
=
True
,
)
elif
op_type
==
"update_file"
:
return
await
gitlab_service
.
create_or_update_file
(
gitlab_url
,
token
,
project_id
,
payload
[
"file_path"
],
payload
[
"content"
],
payload
.
get
(
"commit_message"
,
f
"Update {payload['file_path']}"
),
branch
or
"main"
,
create
=
False
,
)
elif
op_type
==
"create_project"
:
return
await
gitlab_service
.
create_project
(
gitlab_url
,
token
,
payload
[
"name"
],
payload
.
get
(
"description"
,
""
),
payload
.
get
(
"visibility"
,
"private"
),
)
elif
op_type
==
"create_branch"
:
return
await
gitlab_service
.
create_branch
(
gitlab_url
,
token
,
project_id
,
payload
[
"branch_name"
],
payload
.
get
(
"ref"
,
"main"
),
)
elif
op_type
==
"create_mr"
:
return
await
gitlab_service
.
create_merge_request
(
gitlab_url
,
token
,
project_id
,
payload
[
"source_branch"
],
payload
.
get
(
"target_branch"
,
"main"
),
payload
[
"title"
],
payload
.
get
(
"description"
,
""
),
)
elif
op_type
==
"trigger_pipeline"
:
return
await
gitlab_service
.
trigger_pipeline
(
gitlab_url
,
token
,
project_id
,
payload
.
get
(
"ref"
,
branch
or
"main"
),
)
else
:
raise
ValueError
(
f
"Unknown operation type: {op_type}"
)
def
_op_dict
(
op
:
GitLabOperation
)
->
dict
:
return
{
"id"
:
op
.
id
,
"operation_type"
:
op
.
operation_type
,
"status"
:
op
.
status
,
"project_id"
:
op
.
project_id
,
"project_name"
:
op
.
project_name
,
"branch"
:
op
.
branch
,
"payload"
:
json
.
loads
(
op
.
payload
)
if
op
.
payload
else
{},
"result"
:
json
.
loads
(
op
.
result
)
if
op
.
result
and
op
.
result
.
startswith
(
"{"
)
else
op
.
result
,
"chat_id"
:
op
.
chat_id
,
"created_by"
:
op
.
created_by
,
"approved_by"
:
op
.
approved_by
,
"created_at"
:
str
(
op
.
created_at
),
"executed_at"
:
str
(
op
.
executed_at
)
if
op
.
executed_at
else
None
,
}
\ No newline at end of file
backend/services/gitlab_service.py
0 → 100644
View file @
dbe0dcba
"""
GitLab CE API Service.
Handles all communication with a self-hosted GitLab CE instance.
Superadmin-only. Every operation is auditable.
"""
import
base64
import
json
from
typing
import
Optional
from
urllib.parse
import
quote
import
httpx
_client
:
Optional
[
httpx
.
AsyncClient
]
=
None
def
_get_client
()
->
httpx
.
AsyncClient
:
global
_client
if
_client
is
None
or
_client
.
is_closed
:
_client
=
httpx
.
AsyncClient
(
timeout
=
httpx
.
Timeout
(
connect
=
15.0
,
read
=
60.0
,
write
=
30.0
,
pool
=
30.0
),
)
return
_client
async
def
close_gitlab_client
():
global
_client
if
_client
and
not
_client
.
is_closed
:
await
_client
.
aclose
()
_client
=
None
def
_headers
(
token
:
str
)
->
dict
:
return
{
"PRIVATE-TOKEN"
:
token
,
"Content-Type"
:
"application/json"
}
def
_api_url
(
gitlab_url
:
str
,
path
:
str
)
->
str
:
base
=
gitlab_url
.
rstrip
(
"/"
)
return
f
"{base}/api/v4{path}"
def
encode_token
(
plain
:
str
)
->
str
:
return
base64
.
b64encode
(
plain
.
encode
())
.
decode
()
def
decode_token
(
encoded
:
str
)
->
str
:
return
base64
.
b64decode
(
encoded
.
encode
())
.
decode
()
def
mask_token
(
encoded
:
str
)
->
str
:
try
:
plain
=
decode_token
(
encoded
)
if
len
(
plain
)
<=
8
:
return
"****"
return
plain
[:
4
]
+
"…"
+
plain
[
-
4
:]
except
Exception
:
return
"****"
# ══════════════════════════════════════════════════════
# Connection
# ══════════════════════════════════════════════════════
async
def
test_connection
(
gitlab_url
:
str
,
token
:
str
)
->
dict
:
client
=
_get_client
()
try
:
resp
=
await
client
.
get
(
_api_url
(
gitlab_url
,
"/user"
),
headers
=
_headers
(
token
),
)
if
resp
.
status_code
==
200
:
user
=
resp
.
json
()
return
{
"ok"
:
True
,
"user"
:
user
.
get
(
"username"
),
"name"
:
user
.
get
(
"name"
),
"email"
:
user
.
get
(
"email"
),
"is_admin"
:
user
.
get
(
"is_admin"
,
False
),
"gitlab_version"
:
resp
.
headers
.
get
(
"x-gitlab-meta"
,
""
),
}
return
{
"ok"
:
False
,
"error"
:
f
"HTTP {resp.status_code}: {resp.text[:200]}"
}
except
Exception
as
e
:
return
{
"ok"
:
False
,
"error"
:
str
(
e
)}
# ══════════════════════════════════════════════════════
# Projects / Repositories
# ══════════════════════════════════════════════════════
async
def
list_projects
(
gitlab_url
:
str
,
token
:
str
,
search
:
str
=
""
,
page
:
int
=
1
,
per_page
:
int
=
20
,
owned
:
bool
=
True
,
order_by
:
str
=
"updated_at"
,
)
->
dict
:
client
=
_get_client
()
params
=
{
"page"
:
page
,
"per_page"
:
per_page
,
"order_by"
:
order_by
,
"sort"
:
"desc"
,
"owned"
:
str
(
owned
)
.
lower
(),
}
if
search
:
params
[
"search"
]
=
search
resp
=
await
client
.
get
(
_api_url
(
gitlab_url
,
"/projects"
),
headers
=
_headers
(
token
),
params
=
params
,
)
resp
.
raise_for_status
()
total
=
int
(
resp
.
headers
.
get
(
"x-total"
,
"0"
))
total_pages
=
int
(
resp
.
headers
.
get
(
"x-total-pages"
,
"1"
))
return
{
"projects"
:
resp
.
json
(),
"total"
:
total
,
"page"
:
page
,
"total_pages"
:
total_pages
,
}
async
def
get_project
(
gitlab_url
:
str
,
token
:
str
,
project_id
:
int
)
->
dict
:
client
=
_get_client
()
resp
=
await
client
.
get
(
_api_url
(
gitlab_url
,
f
"/projects/{project_id}"
),
headers
=
_headers
(
token
),
)
resp
.
raise_for_status
()
return
resp
.
json
()
async
def
create_project
(
gitlab_url
:
str
,
token
:
str
,
name
:
str
,
description
:
str
=
""
,
visibility
:
str
=
"private"
,
namespace_id
:
Optional
[
int
]
=
None
,
initialize_with_readme
:
bool
=
True
,
)
->
dict
:
client
=
_get_client
()
body
=
{
"name"
:
name
,
"description"
:
description
,
"visibility"
:
visibility
,
"initialize_with_readme"
:
initialize_with_readme
,
}
if
namespace_id
:
body
[
"namespace_id"
]
=
namespace_id
resp
=
await
client
.
post
(
_api_url
(
gitlab_url
,
"/projects"
),
headers
=
_headers
(
token
),
json
=
body
,
)
resp
.
raise_for_status
()
return
resp
.
json
()
async
def
delete_project
(
gitlab_url
:
str
,
token
:
str
,
project_id
:
int
)
->
bool
:
client
=
_get_client
()
resp
=
await
client
.
delete
(
_api_url
(
gitlab_url
,
f
"/projects/{project_id}"
),
headers
=
_headers
(
token
),
)
return
resp
.
status_code
in
(
200
,
202
,
204
)
async
def
fork_project
(
gitlab_url
:
str
,
token
:
str
,
project_id
:
int
,
name
:
Optional
[
str
]
=
None
,
namespace_id
:
Optional
[
int
]
=
None
,
)
->
dict
:
client
=
_get_client
()
body
=
{}
if
name
:
body
[
"name"
]
=
name
if
namespace_id
:
body
[
"namespace_id"
]
=
namespace_id
resp
=
await
client
.
post
(
_api_url
(
gitlab_url
,
f
"/projects/{project_id}/fork"
),
headers
=
_headers
(
token
),
json
=
body
,
)
resp
.
raise_for_status
()
return
resp
.
json
()
# ══════════════════════════════════════════════════════
# Namespaces / Groups
# ══════════════════════════════════════════════════════
async
def
list_namespaces
(
gitlab_url
:
str
,
token
:
str
)
->
list
:
client
=
_get_client
()
resp
=
await
client
.
get
(
_api_url
(
gitlab_url
,
"/namespaces"
),
headers
=
_headers
(
token
),
params
=
{
"per_page"
:
100
},
)
resp
.
raise_for_status
()
return
resp
.
json
()
# ══════════════════════════════════════════════════════
# Branches
# ══════════════════════════════════════════════════════
async
def
list_branches
(
gitlab_url
:
str
,
token
:
str
,
project_id
:
int
)
->
list
:
client
=
_get_client
()
resp
=
await
client
.
get
(
_api_url
(
gitlab_url
,
f
"/projects/{project_id}/repository/branches"
),
headers
=
_headers
(
token
),
params
=
{
"per_page"
:
100
},
)
resp
.
raise_for_status
()
return
resp
.
json
()
async
def
create_branch
(
gitlab_url
:
str
,
token
:
str
,
project_id
:
int
,
branch_name
:
str
,
ref
:
str
=
"main"
,
)
->
dict
:
client
=
_get_client
()
resp
=
await
client
.
post
(
_api_url
(
gitlab_url
,
f
"/projects/{project_id}/repository/branches"
),
headers
=
_headers
(
token
),
json
=
{
"branch"
:
branch_name
,
"ref"
:
ref
},
)
resp
.
raise_for_status
()
return
resp
.
json
()
async
def
delete_branch
(
gitlab_url
:
str
,
token
:
str
,
project_id
:
int
,
branch_name
:
str
,
)
->
bool
:
client
=
_get_client
()
encoded
=
quote
(
branch_name
,
safe
=
""
)
resp
=
await
client
.
delete
(
_api_url
(
gitlab_url
,
f
"/projects/{project_id}/repository/branches/{encoded}"
),
headers
=
_headers
(
token
),
)
return
resp
.
status_code
in
(
200
,
204
)
# ══════════════════════════════════════════════════════
# File Tree / Repository Browser
# ══════════════════════════════════════════════════════
async
def
get_tree
(
gitlab_url
:
str
,
token
:
str
,
project_id
:
int
,
path
:
str
=
""
,
ref
:
str
=
"main"
,
recursive
:
bool
=
False
,
)
->
list
:
client
=
_get_client
()
params
=
{
"ref"
:
ref
,
"per_page"
:
100
,
"recursive"
:
str
(
recursive
)
.
lower
()}
if
path
:
params
[
"path"
]
=
path
resp
=
await
client
.
get
(
_api_url
(
gitlab_url
,
f
"/projects/{project_id}/repository/tree"
),
headers
=
_headers
(
token
),
params
=
params
,
)
if
resp
.
status_code
==
404
:
return
[]
resp
.
raise_for_status
()
return
resp
.
json
()
async
def
get_file
(
gitlab_url
:
str
,
token
:
str
,
project_id
:
int
,
file_path
:
str
,
ref
:
str
=
"main"
,
)
->
dict
:
client
=
_get_client
()
encoded_path
=
quote
(
file_path
,
safe
=
""
)
resp
=
await
client
.
get
(
_api_url
(
gitlab_url
,
f
"/projects/{project_id}/repository/files/{encoded_path}"
),
headers
=
_headers
(
token
),
params
=
{
"ref"
:
ref
},
)
resp
.
raise_for_status
()
data
=
resp
.
json
()
if
data
.
get
(
"encoding"
)
==
"base64"
and
data
.
get
(
"content"
):
try
:
data
[
"decoded_content"
]
=
base64
.
b64decode
(
data
[
"content"
])
.
decode
(
"utf-8"
)
except
Exception
:
data
[
"decoded_content"
]
=
"[Binary file — cannot decode]"
return
data
async
def
create_or_update_file
(
gitlab_url
:
str
,
token
:
str
,
project_id
:
int
,
file_path
:
str
,
content
:
str
,
commit_message
:
str
,
branch
:
str
=
"main"
,
create
:
bool
=
False
,
)
->
dict
:
client
=
_get_client
()
encoded_path
=
quote
(
file_path
,
safe
=
""
)
url
=
_api_url
(
gitlab_url
,
f
"/projects/{project_id}/repository/files/{encoded_path}"
)
body
=
{
"branch"
:
branch
,
"content"
:
content
,
"commit_message"
:
commit_message
,
}
if
create
:
resp
=
await
client
.
post
(
url
,
headers
=
_headers
(
token
),
json
=
body
)
else
:
resp
=
await
client
.
put
(
url
,
headers
=
_headers
(
token
),
json
=
body
)
resp
.
raise_for_status
()
return
resp
.
json
()
async
def
delete_file
(
gitlab_url
:
str
,
token
:
str
,
project_id
:
int
,
file_path
:
str
,
commit_message
:
str
,
branch
:
str
=
"main"
,
)
->
dict
:
client
=
_get_client
()
encoded_path
=
quote
(
file_path
,
safe
=
""
)
resp
=
await
client
.
delete
(
_api_url
(
gitlab_url
,
f
"/projects/{project_id}/repository/files/{encoded_path}"
),
headers
=
_headers
(
token
),
json
=
{
"branch"
:
branch
,
"commit_message"
:
commit_message
},
)
resp
.
raise_for_status
()
return
resp
.
json
()
# ══════════════════════════════════════════════════════
# Batch Commits (THE SURGICAL WEAPON)
# ══════════════════════════════════════════════════════
async
def
batch_commit
(
gitlab_url
:
str
,
token
:
str
,
project_id
:
int
,
branch
:
str
,
commit_message
:
str
,
actions
:
list
[
dict
],
)
->
dict
:
"""
Perform a batch commit with multiple file actions.
Each action: {"action": "create"|"update"|"delete", "file_path": "...", "content": "..."}
"""
client
=
_get_client
()
body
=
{
"branch"
:
branch
,
"commit_message"
:
commit_message
,
"actions"
:
actions
,
}
resp
=
await
client
.
post
(
_api_url
(
gitlab_url
,
f
"/projects/{project_id}/repository/commits"
),
headers
=
_headers
(
token
),
json
=
body
,
)
resp
.
raise_for_status
()
return
resp
.
json
()
async
def
smart_batch_commit
(
gitlab_url
:
str
,
token
:
str
,
project_id
:
int
,
branch
:
str
,
commit_message
:
str
,
files
:
list
[
dict
],
)
->
dict
:
"""
Intelligently create or update files. Checks if each file exists first.
files: [{"file_path": "...", "content": "..."}]
"""
actions
=
[]
for
f
in
files
:
exists
=
False
try
:
await
get_file
(
gitlab_url
,
token
,
project_id
,
f
[
"file_path"
],
ref
=
branch
)
exists
=
True
except
Exception
:
pass
actions
.
append
({
"action"
:
"update"
if
exists
else
"create"
,
"file_path"
:
f
[
"file_path"
],
"content"
:
f
[
"content"
],
})
return
await
batch_commit
(
gitlab_url
,
token
,
project_id
,
branch
,
commit_message
,
actions
)
# ══════════════════════════════════════════════════════
# Merge Requests
# ══════════════════════════════════════════════════════
async
def
list_merge_requests
(
gitlab_url
:
str
,
token
:
str
,
project_id
:
int
,
state
:
str
=
"opened"
,
)
->
list
:
client
=
_get_client
()
resp
=
await
client
.
get
(
_api_url
(
gitlab_url
,
f
"/projects/{project_id}/merge_requests"
),
headers
=
_headers
(
token
),
params
=
{
"state"
:
state
,
"per_page"
:
50
,
"order_by"
:
"updated_at"
,
"sort"
:
"desc"
},
)
resp
.
raise_for_status
()
return
resp
.
json
()
async
def
create_merge_request
(
gitlab_url
:
str
,
token
:
str
,
project_id
:
int
,
source_branch
:
str
,
target_branch
:
str
,
title
:
str
,
description
:
str
=
""
,
)
->
dict
:
client
=
_get_client
()
body
=
{
"source_branch"
:
source_branch
,
"target_branch"
:
target_branch
,
"title"
:
title
,
"description"
:
description
,
"remove_source_branch"
:
True
,
}
resp
=
await
client
.
post
(
_api_url
(
gitlab_url
,
f
"/projects/{project_id}/merge_requests"
),
headers
=
_headers
(
token
),
json
=
body
,
)
resp
.
raise_for_status
()
return
resp
.
json
()
async
def
get_merge_request_changes
(
gitlab_url
:
str
,
token
:
str
,
project_id
:
int
,
mr_iid
:
int
,
)
->
dict
:
client
=
_get_client
()
resp
=
await
client
.
get
(
_api_url
(
gitlab_url
,
f
"/projects/{project_id}/merge_requests/{mr_iid}/changes"
),
headers
=
_headers
(
token
),
)
resp
.
raise_for_status
()
return
resp
.
json
()
# ══════════════════════════════════════════════════════
# Pipelines
# ══════════════════════════════════════════════════════
async
def
list_pipelines
(
gitlab_url
:
str
,
token
:
str
,
project_id
:
int
,
)
->
list
:
client
=
_get_client
()
resp
=
await
client
.
get
(
_api_url
(
gitlab_url
,
f
"/projects/{project_id}/pipelines"
),
headers
=
_headers
(
token
),
params
=
{
"per_page"
:
30
},
)
resp
.
raise_for_status
()
return
resp
.
json
()
async
def
get_pipeline
(
gitlab_url
:
str
,
token
:
str
,
project_id
:
int
,
pipeline_id
:
int
,
)
->
dict
:
client
=
_get_client
()
resp
=
await
client
.
get
(
_api_url
(
gitlab_url
,
f
"/projects/{project_id}/pipelines/{pipeline_id}"
),
headers
=
_headers
(
token
),
)
resp
.
raise_for_status
()
return
resp
.
json
()
async
def
get_pipeline_jobs
(
gitlab_url
:
str
,
token
:
str
,
project_id
:
int
,
pipeline_id
:
int
,
)
->
list
:
client
=
_get_client
()
resp
=
await
client
.
get
(
_api_url
(
gitlab_url
,
f
"/projects/{project_id}/pipelines/{pipeline_id}/jobs"
),
headers
=
_headers
(
token
),
params
=
{
"per_page"
:
100
},
)
resp
.
raise_for_status
()
return
resp
.
json
()
async
def
trigger_pipeline
(
gitlab_url
:
str
,
token
:
str
,
project_id
:
int
,
ref
:
str
=
"main"
,
)
->
dict
:
client
=
_get_client
()
resp
=
await
client
.
post
(
_api_url
(
gitlab_url
,
f
"/projects/{project_id}/pipeline"
),
headers
=
_headers
(
token
),
json
=
{
"ref"
:
ref
},
)
resp
.
raise_for_status
()
return
resp
.
json
()
async
def
retry_pipeline
(
gitlab_url
:
str
,
token
:
str
,
project_id
:
int
,
pipeline_id
:
int
,
)
->
dict
:
client
=
_get_client
()
resp
=
await
client
.
post
(
_api_url
(
gitlab_url
,
f
"/projects/{project_id}/pipelines/{pipeline_id}/retry"
),
headers
=
_headers
(
token
),
)
resp
.
raise_for_status
()
return
resp
.
json
()
# ══════════════════════════════════════════════════════
# Compare / Diff
# ══════════════════════════════════════════════════════
async
def
compare
(
gitlab_url
:
str
,
token
:
str
,
project_id
:
int
,
from_ref
:
str
,
to_ref
:
str
,
)
->
dict
:
client
=
_get_client
()
resp
=
await
client
.
get
(
_api_url
(
gitlab_url
,
f
"/projects/{project_id}/repository/compare"
),
headers
=
_headers
(
token
),
params
=
{
"from"
:
from_ref
,
"to"
:
to_ref
},
)
resp
.
raise_for_status
()
return
resp
.
json
()
\ No newline at end of file
frontend/src/App.jsx
View file @
dbe0dcba
...
@@ -7,26 +7,20 @@ import LoginPage from "./pages/LoginPage";
...
@@ -7,26 +7,20 @@ import LoginPage from "./pages/LoginPage";
import
ChatPage
from
"./pages/ChatPage"
;
import
ChatPage
from
"./pages/ChatPage"
;
import
AdminPage
from
"./pages/AdminPage"
;
import
AdminPage
from
"./pages/AdminPage"
;
import
KnowledgePage
from
"./pages/KnowledgePage"
;
import
KnowledgePage
from
"./pages/KnowledgePage"
;
import
GitLabPage
from
"./pages/GitLabPage"
;
import
{
Flame
}
from
"lucide-react"
;
import
{
Flame
}
from
"lucide-react"
;
export
default
function
App
()
{
export
default
function
App
()
{
const
{
state
,
dispatch
}
=
useApp
();
const
{
state
,
dispatch
}
=
useApp
();
const
[
authChecked
,
setAuthChecked
]
=
useState
(
!
state
.
token
);
const
[
authChecked
,
setAuthChecked
]
=
useState
(
!
state
.
token
);
// Connect streamManager to store dispatch
useEffect
(()
=>
{
useEffect
(()
=>
{
streamManager
.
setDispatch
(
dispatch
);
streamManager
.
setDispatch
(
dispatch
);
},
[
dispatch
]);
},
[
dispatch
]);
useEffect
(()
=>
{
useEffect
(()
=>
{
if
(
!
state
.
token
)
{
if
(
!
state
.
token
)
{
setAuthChecked
(
true
);
return
;
}
setAuthChecked
(
true
);
if
(
state
.
user
)
{
setAuthChecked
(
true
);
return
;
}
return
;
}
if
(
state
.
user
)
{
setAuthChecked
(
true
);
return
;
}
(
async
()
=>
{
(
async
()
=>
{
try
{
try
{
const
user
=
await
getMe
(
state
.
token
);
const
user
=
await
getMe
(
state
.
token
);
...
@@ -52,14 +46,13 @@ export default function App() {
...
@@ -52,14 +46,13 @@ export default function App() {
);
);
}
}
if
(
!
state
.
token
)
{
if
(
!
state
.
token
)
return
<
LoginPage
/>;
return
<
LoginPage
/>;
}
return
(
return
(
<
Routes
>
<
Routes
>
<
Route
path=
"/admin"
element=
{
<
AdminPage
/>
}
/>
<
Route
path=
"/admin"
element=
{
<
AdminPage
/>
}
/>
<
Route
path=
"/knowledge"
element=
{
<
KnowledgePage
/>
}
/>
<
Route
path=
"/knowledge"
element=
{
<
KnowledgePage
/>
}
/>
<
Route
path=
"/gitlab"
element=
{
<
GitLabPage
/>
}
/>
<
Route
path=
"/*"
element=
{
<
ChatPage
/>
}
/>
<
Route
path=
"/*"
element=
{
<
ChatPage
/>
}
/>
</
Routes
>
</
Routes
>
);
);
...
...
frontend/src/api.js
View file @
dbe0dcba
...
@@ -24,48 +24,29 @@ async function request(method, path, token, body) {
...
@@ -24,48 +24,29 @@ async function request(method, path, token, body) {
// ═══════════════════════════════════════════════════
// ═══════════════════════════════════════════════════
// Auth
// Auth
// ═══════════════════════════════════════════════════
// ═══════════════════════════════════════════════════
export
const
login
=
(
username
,
password
)
=>
export
const
login
=
(
username
,
password
)
=>
request
(
"POST"
,
"/auth/login"
,
null
,
{
username
,
password
});
request
(
"POST"
,
"/auth/login"
,
null
,
{
username
,
password
});
export
const
register
=
(
username
,
email
,
password
)
=>
export
const
register
=
(
username
,
email
,
password
)
=>
request
(
"POST"
,
"/auth/register"
,
null
,
{
username
,
email
,
password
});
request
(
"POST"
,
"/auth/register"
,
null
,
{
username
,
email
,
password
});
export
const
getMe
=
(
token
)
=>
request
(
"GET"
,
"/auth/me"
,
token
);
export
const
getMe
=
(
token
)
=>
request
(
"GET"
,
"/auth/me"
,
token
);
// ═══════════════════════════════════════════════════
// ═══════════════════════════════════════════════════
// Chats
// Chats
// ═══════════════════════════════════════════════════
// ═══════════════════════════════════════════════════
export
const
listChats
=
(
token
)
=>
request
(
"GET"
,
"/chats"
,
token
);
export
const
listChats
=
(
token
)
=>
request
(
"GET"
,
"/chats"
,
token
);
export
const
createChat
=
(
token
,
data
=
{})
=>
request
(
"POST"
,
"/chats"
,
token
,
data
);
export
const
createChat
=
(
token
,
data
=
{})
=>
request
(
"POST"
,
"/chats"
,
token
,
data
);
export
const
updateChat
=
(
token
,
chatId
,
data
)
=>
request
(
"PUT"
,
`/chats/
${
chatId
}
`
,
token
,
data
);
export
const
updateChat
=
(
token
,
chatId
,
data
)
=>
export
const
renameChat
=
(
token
,
chatId
,
title
)
=>
updateChat
(
token
,
chatId
,
{
title
});
request
(
"PUT"
,
`/chats/
${
chatId
}
`
,
token
,
data
);
export
const
deleteChat
=
(
token
,
chatId
)
=>
request
(
"DELETE"
,
`/chats/
${
chatId
}
`
,
token
);
export
const
getMessages
=
(
token
,
chatId
)
=>
request
(
"GET"
,
`/chats/
${
chatId
}
/messages`
,
token
);
export
const
renameChat
=
(
token
,
chatId
,
title
)
=>
export
const
checkGenerating
=
(
token
,
chatId
)
=>
request
(
"GET"
,
`/chats/
${
chatId
}
/generating`
,
token
);
updateChat
(
token
,
chatId
,
{
title
});
export
const
deleteChat
=
(
token
,
chatId
)
=>
request
(
"DELETE"
,
`/chats/
${
chatId
}
`
,
token
);
export
const
getMessages
=
(
token
,
chatId
)
=>
request
(
"GET"
,
`/chats/
${
chatId
}
/messages`
,
token
);
export
const
checkGenerating
=
(
token
,
chatId
)
=>
request
(
"GET"
,
`/chats/
${
chatId
}
/generating`
,
token
);
// ═══════════════════════════════════════════════════
// ═══════════════════════════════════════════════════
// Streaming
// Streaming
// ═══════════════════════════════════════════════════
// ═══════════════════════════════════════════════════
export
async
function
*
streamMessage
(
token
,
chatId
,
body
,
signal
)
{
export
async
function
*
streamMessage
(
token
,
chatId
,
body
,
signal
)
{
const
res
=
await
fetch
(
`
${
BASE
}
/chats/
${
chatId
}
/messages`
,
{
const
res
=
await
fetch
(
`
${
BASE
}
/chats/
${
chatId
}
/messages`
,
{
method
:
"POST"
,
method
:
"POST"
,
headers
:
headers
(
token
),
body
:
JSON
.
stringify
(
body
),
signal
,
headers
:
headers
(
token
),
body
:
JSON
.
stringify
(
body
),
signal
,
});
});
if
(
!
res
.
ok
)
{
if
(
!
res
.
ok
)
{
const
err
=
await
res
.
json
().
catch
(()
=>
({
detail
:
res
.
statusText
}));
const
err
=
await
res
.
json
().
catch
(()
=>
({
detail
:
res
.
statusText
}));
...
@@ -82,122 +63,60 @@ export async function* streamMessage(token, chatId, body, signal) {
...
@@ -82,122 +63,60 @@ export async function* streamMessage(token, chatId, body, signal) {
buffer
=
parts
.
pop
()
||
""
;
buffer
=
parts
.
pop
()
||
""
;
for
(
const
part
of
parts
)
{
for
(
const
part
of
parts
)
{
const
line
=
part
.
trim
();
const
line
=
part
.
trim
();
if
(
line
.
startsWith
(
"data: "
))
{
if
(
line
.
startsWith
(
"data: "
))
{
try
{
yield
JSON
.
parse
(
line
.
slice
(
6
));
}
catch
{
}
}
try
{
yield
JSON
.
parse
(
line
.
slice
(
6
));
}
catch
{
/* skip */
}
}
}
}
if
(
buffer
.
trim
().
startsWith
(
"data: "
))
{
try
{
yield
JSON
.
parse
(
buffer
.
trim
().
slice
(
6
));
}
catch
{
/* skip */
}
}
}
}
if
(
buffer
.
trim
().
startsWith
(
"data: "
))
{
try
{
yield
JSON
.
parse
(
buffer
.
trim
().
slice
(
6
));
}
catch
{
}
}
}
}
// ═══════════════════════════════════════════════════
// ═══════════════════════════════════════════════════
// Chat Attachments
// Chat Attachments
// ═══════════════════════════════════════════════════
// ═══════════════════════════════════════════════════
export
async
function
uploadAttachments
(
token
,
chatId
,
files
)
{
export
async
function
uploadAttachments
(
token
,
chatId
,
files
)
{
const
form
=
new
FormData
();
const
form
=
new
FormData
();
for
(
const
file
of
files
)
form
.
append
(
"files"
,
file
);
for
(
const
file
of
files
)
form
.
append
(
"files"
,
file
);
const
res
=
await
fetch
(
`
${
BASE
}
/chats/
${
chatId
}
/attachments`
,
{
const
res
=
await
fetch
(
`
${
BASE
}
/chats/
${
chatId
}
/attachments`
,
{
method
:
"POST"
,
headers
:
authHeader
(
token
),
body
:
form
});
method
:
"POST"
,
if
(
!
res
.
ok
)
{
const
err
=
await
res
.
json
().
catch
(()
=>
({}));
throw
new
Error
(
err
.
detail
||
"Upload failed"
);
}
headers
:
authHeader
(
token
),
body
:
form
,
});
if
(
!
res
.
ok
)
{
const
err
=
await
res
.
json
().
catch
(()
=>
({}));
throw
new
Error
(
err
.
detail
||
"Upload failed"
);
}
return
res
.
json
();
return
res
.
json
();
}
}
export
function
getAttachmentUrl
(
attachmentId
)
{
return
`
${
BASE
}
/attachments/
${
attachmentId
}
/file`
;
}
export
function
getAttachmentUrl
(
attachmentId
)
{
export
const
deleteAttachment
=
(
token
,
attachmentId
)
=>
request
(
"DELETE"
,
`/attachments/
${
attachmentId
}
`
,
token
);
return
`
${
BASE
}
/attachments/
${
attachmentId
}
/file`
;
}
export
const
deleteAttachment
=
(
token
,
attachmentId
)
=>
request
(
"DELETE"
,
`/attachments/
${
attachmentId
}
`
,
token
);
// ═══════════════════════════════════════════════════
// ═══════════════════════════════════════════════════
// Knowledge Bases
// Knowledge Bases
// ═══════════════════════════════════════════════════
// ═══════════════════════════════════════════════════
export
const
listKnowledgeBases
=
(
token
)
=>
request
(
"GET"
,
"/knowledge"
,
token
);
export
const
listKnowledgeBases
=
(
token
)
=>
export
const
createKnowledgeBase
=
(
token
,
name
,
description
=
""
)
=>
request
(
"POST"
,
"/knowledge"
,
token
,
{
name
,
description
});
request
(
"GET"
,
"/knowledge"
,
token
);
export
const
getKnowledgeBase
=
(
token
,
kbId
)
=>
request
(
"GET"
,
`/knowledge/
${
kbId
}
`
,
token
);
export
const
updateKnowledgeBase
=
(
token
,
kbId
,
data
)
=>
request
(
"PUT"
,
`/knowledge/
${
kbId
}
`
,
token
,
data
);
export
const
createKnowledgeBase
=
(
token
,
name
,
description
=
""
)
=>
export
const
deleteKnowledgeBase
=
(
token
,
kbId
)
=>
request
(
"DELETE"
,
`/knowledge/
${
kbId
}
`
,
token
);
request
(
"POST"
,
"/knowledge"
,
token
,
{
name
,
description
});
export
const
listKnowledgeDocuments
=
(
token
,
kbId
)
=>
request
(
"GET"
,
`/knowledge/
${
kbId
}
/documents`
,
token
);
export
const
deleteKnowledgeDocument
=
(
token
,
kbId
,
docId
)
=>
request
(
"DELETE"
,
`/knowledge/
${
kbId
}
/documents/
${
docId
}
`
,
token
);
export
const
getKnowledgeBase
=
(
token
,
kbId
)
=>
request
(
"GET"
,
`/knowledge/
${
kbId
}
`
,
token
);
export
const
updateKnowledgeBase
=
(
token
,
kbId
,
data
)
=>
request
(
"PUT"
,
`/knowledge/
${
kbId
}
`
,
token
,
data
);
export
const
deleteKnowledgeBase
=
(
token
,
kbId
)
=>
request
(
"DELETE"
,
`/knowledge/
${
kbId
}
`
,
token
);
// ═══════════════════════════════════════════════════
// Knowledge Base Documents
// ═══════════════════════════════════════════════════
export
const
listKnowledgeDocuments
=
(
token
,
kbId
)
=>
request
(
"GET"
,
`/knowledge/
${
kbId
}
/documents`
,
token
);
export
const
deleteKnowledgeDocument
=
(
token
,
kbId
,
docId
)
=>
request
(
"DELETE"
,
`/knowledge/
${
kbId
}
/documents/
${
docId
}
`
,
token
);
export
async
function
uploadDocuments
(
token
,
kbId
,
files
)
{
export
async
function
uploadDocuments
(
token
,
kbId
,
files
)
{
const
form
=
new
FormData
();
const
form
=
new
FormData
();
for
(
const
file
of
files
)
form
.
append
(
"files"
,
file
);
for
(
const
file
of
files
)
form
.
append
(
"files"
,
file
);
const
res
=
await
fetch
(
`
${
BASE
}
/knowledge/
${
kbId
}
/upload`
,
{
const
res
=
await
fetch
(
`
${
BASE
}
/knowledge/
${
kbId
}
/upload`
,
{
method
:
"POST"
,
headers
:
authHeader
(
token
),
body
:
form
});
method
:
"POST"
,
if
(
!
res
.
ok
)
{
const
err
=
await
res
.
json
().
catch
(()
=>
({}));
throw
new
Error
(
err
.
detail
||
"Upload failed"
);
}
headers
:
authHeader
(
token
),
body
:
form
,
});
if
(
!
res
.
ok
)
{
const
err
=
await
res
.
json
().
catch
(()
=>
({}));
throw
new
Error
(
err
.
detail
||
"Upload failed"
);
}
return
res
.
json
();
return
res
.
json
();
}
}
export
const
uploadDocument
=
(
token
,
kbId
,
file
)
=>
uploadDocuments
(
token
,
kbId
,
[
file
]);
export
const
uploadDocument
=
(
token
,
kbId
,
file
)
=>
uploadDocuments
(
token
,
kbId
,
[
file
]);
// ═══════════════════════════════════════════════════
// ═══════════════════════════════════════════════════
// Admin
// Admin
// ═══════════════════════════════════════════════════
// ═══════════════════════════════════════════════════
export
const
adminStats
=
(
token
)
=>
request
(
"GET"
,
"/admin/stats"
,
token
);
export
const
adminStats
=
(
token
)
=>
request
(
"GET"
,
"/admin/stats"
,
token
);
export
const
adminListUsers
=
(
token
)
=>
export
const
adminListUsers
=
(
token
)
=>
request
(
"GET"
,
"/admin/users"
,
token
);
request
(
"GET"
,
"/admin/users"
,
token
);
export
const
adminCreateUser
=
(
token
,
data
)
=>
request
(
"POST"
,
"/admin/users"
,
token
,
data
);
export
const
adminCreateUser
=
(
token
,
data
)
=>
export
const
adminUpdateUser
=
(
token
,
userId
,
data
)
=>
request
(
"PUT"
,
`/admin/users/
${
userId
}
`
,
token
,
data
);
request
(
"POST"
,
"/admin/users"
,
token
,
data
);
export
const
adminDeleteUser
=
(
token
,
userId
)
=>
request
(
"DELETE"
,
`/admin/users/
${
userId
}
`
,
token
);
export
const
adminUpdateUser
=
(
token
,
userId
,
data
)
=>
export
const
adminListChats
=
(
token
)
=>
request
(
"GET"
,
"/admin/chats"
,
token
);
request
(
"PUT"
,
`/admin/users/
${
userId
}
`
,
token
,
data
);
export
const
adminDeleteUser
=
(
token
,
userId
)
=>
request
(
"DELETE"
,
`/admin/users/
${
userId
}
`
,
token
);
export
const
adminListChats
=
(
token
)
=>
request
(
"GET"
,
"/admin/chats"
,
token
);
// ═══════════════════════════════════════════════════
// ═══════════════════════════════════════════════════
// Code Download
// Code Download
// ═══════════════════════════════════════════════════
// ═══════════════════════════════════════════════════
export
async
function
downloadZip
(
token
,
markdown
,
chatTitle
)
{
export
async
function
downloadZip
(
token
,
markdown
,
chatTitle
)
{
const
res
=
await
fetch
(
`
${
BASE
}
/files/download-zip`
,
{
const
res
=
await
fetch
(
`
${
BASE
}
/files/download-zip`
,
{
method
:
"POST"
,
method
:
"POST"
,
headers
:
headers
(
token
),
body
:
JSON
.
stringify
({
markdown
,
title
:
chatTitle
||
null
}),
headers
:
headers
(
token
),
body
:
JSON
.
stringify
({
markdown
,
title
:
chatTitle
||
null
}),
});
});
if
(
!
res
.
ok
)
throw
new
Error
(
"Download failed"
);
if
(
!
res
.
ok
)
throw
new
Error
(
"Download failed"
);
const
ct
=
res
.
headers
.
get
(
"content-type"
)
||
""
;
const
ct
=
res
.
headers
.
get
(
"content-type"
)
||
""
;
...
@@ -206,12 +125,8 @@ export async function downloadZip(token, markdown, chatTitle) {
...
@@ -206,12 +125,8 @@ export async function downloadZip(token, markdown, chatTitle) {
const
url
=
URL
.
createObjectURL
(
blob
);
const
url
=
URL
.
createObjectURL
(
blob
);
const
a
=
document
.
createElement
(
"a"
);
const
a
=
document
.
createElement
(
"a"
);
a
.
href
=
url
;
a
.
href
=
url
;
// Derive filename from chat title, fallback to generic
const
raw
=
(
chatTitle
||
""
).
trim
();
const
raw
=
(
chatTitle
||
""
).
trim
();
const
safeName
=
const
safeName
=
raw
&&
raw
!==
"New Chat"
?
raw
.
replace
(
/
[^\w\s
-
]
/g
,
""
).
trim
().
replace
(
/
\s
+/g
,
"-"
).
slice
(
0
,
60
)
||
"code"
:
"code"
;
raw
&&
raw
!==
"New Chat"
?
raw
.
replace
(
/
[^\w\s
-
]
/g
,
""
).
trim
().
replace
(
/
\s
+/g
,
"-"
).
slice
(
0
,
60
)
||
"code"
:
"code"
;
a
.
download
=
`
${
safeName
}
.zip`
;
a
.
download
=
`
${
safeName
}
.zip`
;
a
.
click
();
a
.
click
();
URL
.
revokeObjectURL
(
url
);
URL
.
revokeObjectURL
(
url
);
...
@@ -220,3 +135,61 @@ export async function downloadZip(token, markdown, chatTitle) {
...
@@ -220,3 +135,61 @@ export async function downloadZip(token, markdown, chatTitle) {
if
(
data
.
error
)
throw
new
Error
(
data
.
error
);
if
(
data
.
error
)
throw
new
Error
(
data
.
error
);
}
}
}
}
// ═══════════════════════════════════════════════════
// 🔥 GitLab CE Integration (Superadmin Only)
// ═══════════════════════════════════════════════════
// Config
export
const
gitlabGetConfig
=
(
token
)
=>
request
(
"GET"
,
"/gitlab/config"
,
token
);
export
const
gitlabSaveConfig
=
(
token
,
data
)
=>
request
(
"POST"
,
"/gitlab/config"
,
token
,
data
);
export
const
gitlabTestConnection
=
(
token
,
data
)
=>
request
(
"POST"
,
"/gitlab/test-connection"
,
token
,
data
);
// Projects
export
const
gitlabListProjects
=
(
token
,
search
=
""
,
page
=
1
)
=>
request
(
"GET"
,
`/gitlab/projects?search=
${
encodeURIComponent
(
search
)}
&page=
${
page
}
`
,
token
);
export
const
gitlabGetProject
=
(
token
,
projectId
)
=>
request
(
"GET"
,
`/gitlab/projects/
${
projectId
}
`
,
token
);
export
const
gitlabCreateProject
=
(
token
,
data
)
=>
request
(
"POST"
,
"/gitlab/projects"
,
token
,
data
);
export
const
gitlabDeleteProject
=
(
token
,
projectId
)
=>
request
(
"DELETE"
,
`/gitlab/projects/
${
projectId
}
`
,
token
);
// Branches
export
const
gitlabListBranches
=
(
token
,
projectId
)
=>
request
(
"GET"
,
`/gitlab/projects/
${
projectId
}
/branches`
,
token
);
export
const
gitlabCreateBranch
=
(
token
,
projectId
,
data
)
=>
request
(
"POST"
,
`/gitlab/projects/
${
projectId
}
/branches`
,
token
,
data
);
// Files
export
const
gitlabGetTree
=
(
token
,
projectId
,
path
=
""
,
ref
=
"main"
,
recursive
=
false
)
=>
request
(
"GET"
,
`/gitlab/projects/
${
projectId
}
/tree?path=
${
encodeURIComponent
(
path
)}
&ref=
${
ref
}
&recursive=
${
recursive
}
`
,
token
);
export
const
gitlabGetFile
=
(
token
,
projectId
,
filePath
,
ref
=
"main"
)
=>
request
(
"GET"
,
`/gitlab/projects/
${
projectId
}
/files?file_path=
${
encodeURIComponent
(
filePath
)}
&ref=
${
ref
}
`
,
token
);
// Merge Requests
export
const
gitlabListMRs
=
(
token
,
projectId
,
state
=
"opened"
)
=>
request
(
"GET"
,
`/gitlab/projects/
${
projectId
}
/merge-requests?state=
${
state
}
`
,
token
);
export
const
gitlabCreateMR
=
(
token
,
projectId
,
data
)
=>
request
(
"POST"
,
`/gitlab/projects/
${
projectId
}
/merge-requests`
,
token
,
data
);
// Pipelines
export
const
gitlabListPipelines
=
(
token
,
projectId
)
=>
request
(
"GET"
,
`/gitlab/projects/
${
projectId
}
/pipelines`
,
token
);
export
const
gitlabGetPipeline
=
(
token
,
projectId
,
pipelineId
)
=>
request
(
"GET"
,
`/gitlab/projects/
${
projectId
}
/pipelines/
${
pipelineId
}
`
,
token
);
export
const
gitlabTriggerPipeline
=
(
token
,
projectId
,
ref
=
"main"
)
=>
request
(
"POST"
,
`/gitlab/projects/
${
projectId
}
/pipelines/trigger?ref=
${
ref
}
`
,
token
);
// Operations Queue
export
const
gitlabQueueOperation
=
(
token
,
data
)
=>
request
(
"POST"
,
"/gitlab/operations"
,
token
,
data
);
export
const
gitlabListOperations
=
(
token
,
status
=
"pending"
)
=>
request
(
"GET"
,
`/gitlab/operations?status=
${
status
}
`
,
token
);
export
const
gitlabApproveOperation
=
(
token
,
opId
)
=>
request
(
"POST"
,
`/gitlab/operations/
${
opId
}
/approve`
,
token
);
export
const
gitlabRejectOperation
=
(
token
,
opId
)
=>
request
(
"POST"
,
`/gitlab/operations/
${
opId
}
/reject`
,
token
);
export
const
gitlabDeleteOperation
=
(
token
,
opId
)
=>
request
(
"DELETE"
,
`/gitlab/operations/
${
opId
}
`
,
token
);
// Direct Execute (bypass queue)
export
const
gitlabDirectCommit
=
(
token
,
data
)
=>
request
(
"POST"
,
"/gitlab/execute/commit"
,
token
,
data
);
export
const
gitlabDirectFileOp
=
(
token
,
data
)
=>
request
(
"POST"
,
"/gitlab/execute/file"
,
token
,
data
);
// Audit
export
const
gitlabAuditLog
=
(
token
,
page
=
1
)
=>
request
(
"GET"
,
`/gitlab/audit-log?page=
${
page
}
`
,
token
);
// Namespaces
export
const
gitlabListNamespaces
=
(
token
)
=>
request
(
"GET"
,
"/gitlab/namespaces"
,
token
);
\ No newline at end of file
frontend/src/components/Sidebar.jsx
View file @
dbe0dcba
import
React
,
{
useState
}
from
"react"
;
import
React
,
{
useState
,
useEffect
,
useRef
}
from
"react"
;
import
{
useNavigate
}
from
"react-router-dom"
;
import
{
useNavigate
}
from
"react-router-dom"
;
import
{
useApp
}
from
"../store"
;
import
{
useApp
}
from
"../store"
;
import
{
createChat
,
deleteChat
,
renameChat
}
from
"../api"
;
import
{
listChats
,
createChat
,
deleteChat
,
renameChat
}
from
"../api"
;
import
{
import
{
Flame
,
Plus
,
MessageSquare
,
Trash2
,
Edit3
,
Check
,
X
,
Plus
,
Trash2
,
MessageSquare
,
LogOut
,
Shield
,
BookOpen
,
LogOut
,
Shield
,
BookOpen
,
ChevronRight
,
MoreHorizontal
,
Pencil
,
Check
,
X
,
Flame
,
Menu
,
FolderGit2
,
}
from
"lucide-react"
;
}
from
"lucide-react"
;
export
default
function
Sidebar
({
mobile
,
onClos
e
})
{
export
default
function
Sidebar
({
activeChatId
,
onSelectChat
,
isOpen
,
onToggl
e
})
{
const
{
state
,
dispatch
}
=
useApp
();
const
{
state
,
dispatch
}
=
useApp
();
const
nav
igate
=
useNavigate
();
const
nav
=
useNavigate
();
const
[
edit
Id
,
setEdit
Id
]
=
useState
(
null
);
const
[
edit
ingId
,
setEditing
Id
]
=
useState
(
null
);
const
[
editTitle
,
setEditTitle
]
=
useState
(
""
);
const
[
editTitle
,
setEditTitle
]
=
useState
(
""
);
const
[
menuId
,
setMenuId
]
=
useState
(
null
);
const
menuRef
=
useRef
(
null
);
useEffect
(()
=>
{
(
async
()
=>
{
try
{
const
chats
=
await
listChats
(
state
.
token
);
dispatch
({
type
:
"SET_CHATS"
,
chats
});
}
catch
{
/* ignore */
}
})();
},
[
state
.
token
,
dispatch
]);
useEffect
(()
=>
{
function
handleClick
(
e
)
{
if
(
menuRef
.
current
&&
!
menuRef
.
current
.
contains
(
e
.
target
))
setMenuId
(
null
);
}
document
.
addEventListener
(
"mousedown"
,
handleClick
);
return
()
=>
document
.
removeEventListener
(
"mousedown"
,
handleClick
);
},
[]);
async
function
handleNew
()
{
async
function
handleNew
()
{
try
{
try
{
const
chat
=
await
createChat
(
state
.
token
);
const
chat
=
await
createChat
(
state
.
token
);
dispatch
({
type
:
"ADD_CHAT"
,
chat
});
dispatch
({
type
:
"ADD_CHAT"
,
chat
});
onSelectChat
(
chat
.
id
);
}
catch
{
/* ignore */
}
}
catch
{
/* ignore */
}
}
}
async
function
handleDelete
(
e
,
chatId
)
{
async
function
handleDelete
(
id
)
{
e
.
stopPropagation
();
if
(
!
confirm
(
"Delete this chat?"
))
return
;
try
{
try
{
await
deleteChat
(
state
.
token
,
chatId
);
await
deleteChat
(
state
.
token
,
id
);
dispatch
({
type
:
"REMOVE_CHAT"
,
chatId
});
dispatch
({
type
:
"DELETE_CHAT"
,
chatId
:
id
});
}
catch
{
/* ignore */
}
if
(
activeChatId
===
id
)
{
const
remaining
=
state
.
chats
.
filter
((
c
)
=>
c
.
id
!==
id
);
onSelectChat
(
remaining
.
length
?
remaining
[
0
].
id
:
null
);
}
}
}
catch
{
/* ignore */
}
function
startEdit
(
e
,
chat
)
{
setMenuId
(
null
);
e
.
stopPropagation
();
setEditId
(
chat
.
id
);
setEditTitle
(
chat
.
title
);
}
}
async
function
confirmEdit
(
e
)
{
async
function
handleRename
(
id
)
{
e
.
stopPropagation
();
if
(
!
editTitle
.
trim
())
{
setEditingId
(
null
);
return
;
}
if
(
editTitle
.
trim
()
&&
editId
)
{
try
{
try
{
await
renameChat
(
state
.
token
,
editId
,
editTitle
.
trim
()
);
await
renameChat
(
state
.
token
,
id
,
editTitle
);
dispatch
({
type
:
"UPDATE_CHAT"
,
chat
:
{
id
:
editId
,
title
:
editTitle
.
trim
()
}
});
dispatch
({
type
:
"UPDATE_CHAT"
,
chat
:
{
id
,
title
:
editTitle
}
});
}
catch
{
/* ignore */
}
}
catch
{
/* ignore */
}
}
setEditingId
(
null
);
setEditId
(
null
);
}
function
cancelEdit
(
e
)
{
e
.
stopPropagation
();
setEditId
(
null
);
}
}
function
selectChat
(
chatId
)
{
function
startRename
(
chat
)
{
dispatch
({
type
:
"SET_ACTIVE_CHAT"
,
chatId
});
setEditingId
(
chat
.
id
);
}
setEditTitle
(
chat
.
title
);
setMenuId
(
null
);
function
handleLogout
()
{
dispatch
({
type
:
"LOGOUT"
});
}
}
const
isSuperadmin
=
state
.
user
?.
role
===
"superadmin"
;
const
isSuperadmin
=
state
.
user
?.
role
===
"superadmin"
;
return
(
return
(
<
div
className=
{
`flex flex-col bg-anton-surface border-r border-anton-border h-full ${mobile ? "w-full" : "w-64"}`
}
>
<>
{
isOpen
&&
<
div
className=
"fixed inset-0 bg-black/50 z-30 md:hidden"
onClick=
{
onToggle
}
/>
}
<
aside
className=
{
`fixed md:static inset-y-0 left-0 z-40 w-72 bg-anton-surface border-r border-anton-border flex flex-col transition-transform duration-200 ${isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"}`
}
>
{
/* Header */
}
{
/* Header */
}
<
div
className=
"p-3 border-b border-anton-border"
>
<
div
className=
"p-3 border-b border-anton-border"
>
<
div
className=
"flex items-center gap-2.5
mb-3"
>
<
div
className=
"flex items-center gap-2
mb-3"
>
<
div
className=
"w-9 h-9 rounded-xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/20 shrink-
0"
>
<
div
className=
"w-8 h-8 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/1
0"
>
<
Flame
size=
{
18
}
className=
"text-white"
/>
<
Flame
size=
{
16
}
className=
"text-white"
/>
</
div
>
</
div
>
<
div
className=
"flex-1 min-w-0"
>
<
div
>
<
h1
className=
"text-sm font-bold text-white trunca
te"
>
Son of Anton
</
h1
>
<
h1
className=
"text-sm font-bold text-whi
te"
>
Son of Anton
</
h1
>
<
p
className=
"text-[10px] text-anton-muted truncate"
>
{
state
.
user
?.
username
||
""
}
</
p
>
<
p
className=
"text-[10px] text-anton-muted"
>
v3.0 •
{
state
.
user
?.
username
}
</
p
>
</
div
>
</
div
>
{
mobile
&&
(
<
button
onClick=
{
onClose
}
className=
"p-1.5 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition"
>
<
X
size=
{
18
}
/>
</
button
>
)
}
</
div
>
</
div
>
<
button
onClick=
{
handleNew
}
className=
"w-full flex items-center justify-center gap-1.5 bg-anton-accent text-white rounded-lg py-2 text-sm font-medium hover:opacity-90 transition active:scale-[0.98]"
>
<
button
onClick=
{
handleNew
}
className=
"w-full flex items-center justify-center gap-2 py-2.5 px-3 bg-anton-accent text-white rounded-xl text-sm font-medium hover:opacity-90 transition active:scale-[0.98]"
>
<
Plus
size=
{
16
}
/>
New Chat
<
Plus
size=
{
16
}
/>
New Chat
</
button
>
</
button
>
</
div
>
</
div
>
{
/* Chat list */
}
{
/* Chat list */
}
<
div
className=
"flex-1 overflow-y-auto py-1.5 px-1.5 space-y-0.5"
>
<
div
className=
"flex-1 overflow-y-auto py-1"
>
{
state
.
chats
.
length
===
0
&&
(
{
state
.
chats
.
map
((
chat
)
=>
(
<
p
className=
"text-center text-anton-muted text-xs py-8 px-4"
>
<
div
key=
{
chat
.
id
}
className=
{
`group relative mx-1.5 my-0.5 rounded-lg transition ${activeChatId === chat.id ? "bg-anton-card border border-anton-border" : "hover:bg-anton-card/50"}`
}
>
No chats yet. Start a new conversation!
{
editingId
===
chat
.
id
?
(
</
p
>
<
div
className=
"flex items-center gap-1 px-2 py-2"
>
)
}
<
input
value=
{
editTitle
}
onChange=
{
(
e
)
=>
setEditTitle
(
e
.
target
.
value
)
}
onKeyDown=
{
(
e
)
=>
e
.
key
===
"Enter"
&&
handleRename
(
chat
.
id
)
}
className=
"flex-1 bg-anton-bg border border-anton-border rounded px-2 py-1 text-xs text-white focus:outline-none"
autoFocus
/>
{
state
.
chats
.
map
((
chat
)
=>
{
<
button
onClick=
{
()
=>
handleRename
(
chat
.
id
)
}
className=
"p-1 text-green-400 hover:bg-green-500/20 rounded"
><
Check
size=
{
12
}
/></
button
>
const
active
=
chat
.
id
===
state
.
activeChatId
;
<
button
onClick=
{
()
=>
setEditingId
(
null
)
}
className=
"p-1 text-anton-muted hover:bg-anton-card rounded"
><
X
size=
{
12
}
/></
button
>
const
editing
=
editId
===
chat
.
id
;
return
(
<
div
key=
{
chat
.
id
}
onClick=
{
()
=>
!
editing
&&
selectChat
(
chat
.
id
)
}
className=
{
`group flex items-center gap-2 px-2.5 py-2.5 rounded-lg cursor-pointer transition min-h-[44px] ${
active
? "bg-anton-accent/15 text-white border border-anton-accent/30"
: "text-anton-muted hover:bg-anton-card hover:text-white border border-transparent"
}`
}
>
<
MessageSquare
size=
{
14
}
className=
"shrink-0 opacity-50"
/>
{
editing
?
(
<
div
className=
"flex-1 flex items-center gap-1 min-w-0"
onClick=
{
(
e
)
=>
e
.
stopPropagation
()
}
>
<
input
value=
{
editTitle
}
onChange=
{
(
e
)
=>
setEditTitle
(
e
.
target
.
value
)
}
onKeyDown=
{
(
e
)
=>
{
if
(
e
.
key
===
"Enter"
)
confirmEdit
(
e
);
if
(
e
.
key
===
"Escape"
)
cancelEdit
(
e
);
}
}
className=
"flex-1 bg-anton-bg border border-anton-border rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-anton-accent min-w-0"
autoFocus
/>
<
button
onClick=
{
confirmEdit
}
className=
"p-1 text-anton-success"
><
Check
size=
{
14
}
/></
button
>
<
button
onClick=
{
cancelEdit
}
className=
"p-1 text-anton-muted"
><
X
size=
{
14
}
/></
button
>
</
div
>
</
div
>
)
:
(
)
:
(
<>
<
button
onClick=
{
()
=>
{
onSelectChat
(
chat
.
id
);
if
(
window
.
innerWidth
<
768
)
onToggle
?.();
}
}
className=
"w-full text-left px-3 py-2.5 flex items-center gap-2"
>
<
span
className=
"flex-1 text-sm truncate"
>
{
chat
.
title
}
</
span
>
<
MessageSquare
size=
{
14
}
className=
"text-anton-muted shrink-0"
/>
<
div
className=
"flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
>
<
span
className=
"text-sm truncate flex-1"
>
{
chat
.
title
}
</
span
>
<
button
onClick=
{
(
e
)
=>
startEdit
(
e
,
chat
)
}
className=
"p-1 rounded hover:bg-anton-bg transition"
><
Edit3
size=
{
12
}
/></
button
>
<
button
onClick=
{
(
e
)
=>
{
e
.
stopPropagation
();
setMenuId
(
menuId
===
chat
.
id
?
null
:
chat
.
id
);
}
}
className=
"p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-anton-bg text-anton-muted"
>
<
button
onClick=
{
(
e
)
=>
handleDelete
(
e
,
chat
.
id
)
}
className=
"p-1 rounded hover:bg-anton-danger/20 text-anton-danger transition"
><
Trash2
size=
{
12
}
/></
button
>
<
MoreHorizontal
size=
{
12
}
/>
</
button
>
</
button
>
)
}
{
menuId
===
chat
.
id
&&
(
<
div
ref=
{
menuRef
}
className=
"absolute right-2 top-9 z-50 bg-anton-card border border-anton-border rounded-lg shadow-xl py-1 w-36 animate-fade-in"
>
<
button
onClick=
{
()
=>
startRename
(
chat
)
}
className=
"w-full text-left px-3 py-1.5 text-xs hover:bg-anton-bg flex items-center gap-2"
><
Pencil
size=
{
10
}
/>
Rename
</
button
>
<
button
onClick=
{
()
=>
handleDelete
(
chat
.
id
)
}
className=
"w-full text-left px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/10 flex items-center gap-2"
><
Trash2
size=
{
10
}
/>
Delete
</
button
>
</
div
>
</
div
>
</>
)
}
)
}
</
div
>
</
div
>
);
))
}
})
}
</
div
>
</
div
>
{
/* Footer nav */
}
{
/* Bottom nav */
}
<
div
className=
"p-2 border-t border-anton-border space-y-0.5"
>
<
div
className=
"border-t border-anton-border p-2 space-y-0.5"
>
<
button
onClick=
{
()
=>
{
navigate
(
"/knowledge"
);
onClose
?.();
}
}
className=
"w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm text-anton-muted hover:text-white hover:bg-anton-card transition min-h-[44px]"
>
<
BookOpen
size=
{
16
}
/>
Knowledge Bases
<
ChevronRight
size=
{
14
}
className=
"ml-auto opacity-40"
/>
</
button
>
{
isSuperadmin
&&
(
{
isSuperadmin
&&
(
<
button
<>
onClick=
{
()
=>
{
navigate
(
"/admin"
);
onClose
?.();
}
}
<
button
onClick=
{
()
=>
nav
(
"/gitlab"
)
}
className=
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-orange-400 hover:bg-orange-500/10 transition"
>
className=
"w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm text-anton-muted hover:text-white hover:bg-anton-card transition min-h-[44px]"
<
FolderGit2
size=
{
14
}
/>
GitLab Command Center
>
<
Shield
size=
{
16
}
/>
Admin Panel
<
ChevronRight
size=
{
14
}
className=
"ml-auto opacity-40"
/>
</
button
>
</
button
>
)
}
<
button
onClick=
{
()
=>
nav
(
"/admin"
)
}
className=
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:bg-anton-card transition"
>
<
button
<
Shield
size=
{
14
}
/>
Admin Dashboard
onClick=
{
handleLogout
}
className=
"w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm text-anton-muted hover:text-anton-danger hover:bg-anton-danger/10 transition min-h-[44px]"
>
<
LogOut
size=
{
16
}
/>
Sign Out
</
button
>
</
button
>
</>
{
/* Quota display */
}
{
state
.
user
&&
(
<
div
className=
"px-3 py-2 text-[10px] text-anton-muted"
>
<
div
className=
"flex justify-between mb-1"
>
<
span
>
Tokens used
</
span
>
<
span
>
{
((
state
.
user
.
tokens_used_this_month
||
0
)
/
1000
).
toFixed
(
0
)
}
K /
{
((
state
.
user
.
quota_tokens_monthly
||
0
)
/
1000
).
toFixed
(
0
)
}
K
</
span
>
</
div
>
<
div
className=
"h-1 bg-anton-border rounded-full overflow-hidden"
>
<
div
className=
"h-full bg-anton-accent rounded-full transition-all"
style=
{
{
width
:
`${Math.min(100, ((state.user.tokens_used_this_month || 0) / (state.user.quota_tokens_monthly || 1)) * 100)}%`
}
}
/>
</
div
>
</
div
>
)
}
)
}
<
button
onClick=
{
()
=>
nav
(
"/knowledge"
)
}
className=
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:bg-anton-card transition"
>
<
BookOpen
size=
{
14
}
/>
Knowledge Bases
</
button
>
<
button
onClick=
{
()
=>
dispatch
({
type
:
"LOGOUT"
})
}
className=
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-red-400 hover:bg-red-500/10 transition"
>
<
LogOut
size=
{
14
}
/>
Logout
</
button
>
</
div
>
</
div
>
</
div
>
</
aside
>
</>
);
);
}
}
\ No newline at end of file
frontend/src/pages/GitLabPage.jsx
0 → 100644
View file @
dbe0dcba
import
React
,
{
useState
,
useEffect
,
useCallback
}
from
"react"
;
import
{
useNavigate
}
from
"react-router-dom"
;
import
{
useApp
}
from
"../store"
;
import
{
gitlabGetConfig
,
gitlabSaveConfig
,
gitlabTestConnection
,
gitlabListProjects
,
gitlabCreateProject
,
gitlabDeleteProject
,
gitlabGetTree
,
gitlabGetFile
,
gitlabListBranches
,
gitlabCreateBranch
,
gitlabListOperations
,
gitlabApproveOperation
,
gitlabRejectOperation
,
gitlabDeleteOperation
,
gitlabDirectCommit
,
gitlabAuditLog
,
gitlabListPipelines
,
gitlabTriggerPipeline
,
gitlabListMRs
,
}
from
"../api"
;
import
{
ArrowLeft
,
Settings
,
FolderGit2
,
ListChecks
,
ScrollText
,
Plus
,
Trash2
,
RefreshCw
,
Check
,
X
,
ExternalLink
,
GitBranch
,
File
,
Folder
,
ChevronRight
,
ChevronDown
,
Loader2
,
Shield
,
Zap
,
Clock
,
CheckCircle
,
XCircle
,
AlertCircle
,
Play
,
Eye
,
Copy
,
TerminalSquare
,
GitMerge
,
Rocket
,
}
from
"lucide-react"
;
const
TABS
=
[
{
id
:
"settings"
,
label
:
"Settings"
,
icon
:
Settings
},
{
id
:
"repos"
,
label
:
"Repositories"
,
icon
:
FolderGit2
},
{
id
:
"operations"
,
label
:
"Operations"
,
icon
:
ListChecks
},
{
id
:
"audit"
,
label
:
"Audit Log"
,
icon
:
ScrollText
},
];
const
STATUS_STYLES
=
{
pending
:
"bg-yellow-500/20 text-yellow-400 border-yellow-500/30"
,
executed
:
"bg-green-500/20 text-green-400 border-green-500/30"
,
failed
:
"bg-red-500/20 text-red-400 border-red-500/30"
,
rejected
:
"bg-gray-500/20 text-gray-400 border-gray-500/30"
,
};
export
default
function
GitLabPage
()
{
const
{
state
}
=
useApp
();
const
nav
=
useNavigate
();
const
[
tab
,
setTab
]
=
useState
(
"settings"
);
const
[
config
,
setConfig
]
=
useState
(
null
);
const
[
loading
,
setLoading
]
=
useState
(
true
);
useEffect
(()
=>
{
(
async
()
=>
{
try
{
const
c
=
await
gitlabGetConfig
(
state
.
token
);
setConfig
(
c
);
}
catch
{
/* ignore */
}
setLoading
(
false
);
})();
},
[
state
.
token
]);
if
(
state
.
user
?.
role
!==
"superadmin"
)
{
return
(
<
div
className=
"h-dvh flex items-center justify-center bg-anton-bg"
>
<
div
className=
"text-center"
>
<
Shield
size=
{
48
}
className=
"text-red-500 mx-auto mb-4"
/>
<
h1
className=
"text-xl font-bold text-white mb-2"
>
Access Denied
</
h1
>
<
p
className=
"text-anton-muted"
>
Superadmin access required.
</
p
>
<
button
onClick=
{
()
=>
nav
(
"/"
)
}
className=
"mt-4 px-4 py-2 bg-anton-accent rounded-lg text-white text-sm"
>
Back to Chat
</
button
>
</
div
>
</
div
>
);
}
return
(
<
div
className=
"h-dvh flex flex-col bg-anton-bg text-white"
>
{
/* Header */
}
<
div
className=
"border-b border-anton-border bg-anton-surface px-4 py-3 flex items-center gap-3 shrink-0"
>
<
button
onClick=
{
()
=>
nav
(
"/"
)
}
className=
"p-1.5 rounded-lg hover:bg-anton-card text-anton-muted hover:text-white"
>
<
ArrowLeft
size=
{
18
}
/>
</
button
>
<
FolderGit2
size=
{
20
}
className=
"text-orange-400"
/>
<
h1
className=
"text-lg font-bold"
>
GitLab Command Center
</
h1
>
<
span
className=
"text-xs text-anton-muted ml-auto"
>
Superadmin Only
</
span
>
</
div
>
{
/* Tabs */
}
<
div
className=
"border-b border-anton-border bg-anton-surface px-4 flex gap-1 shrink-0 overflow-x-auto"
>
{
TABS
.
map
((
t
)
=>
(
<
button
key=
{
t
.
id
}
onClick=
{
()
=>
setTab
(
t
.
id
)
}
className=
{
`flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium border-b-2 transition whitespace-nowrap ${tab === t.id ? "border-orange-400 text-orange-400" : "border-transparent text-anton-muted hover:text-white"
}`
}
>
<
t
.
icon
size=
{
14
}
/>
{
t
.
label
}
</
button
>
))
}
</
div
>
{
/* Content */
}
<
div
className=
"flex-1 overflow-y-auto p-4"
>
{
loading
?
(
<
div
className=
"flex items-center justify-center py-20"
>
<
Loader2
className=
"animate-spin text-anton-accent"
size=
{
24
}
/>
</
div
>
)
:
(
<>
{
tab
===
"settings"
&&
<
SettingsTab
config=
{
config
}
setConfig=
{
setConfig
}
token=
{
state
.
token
}
/>
}
{
tab
===
"repos"
&&
<
ReposTab
config=
{
config
}
token=
{
state
.
token
}
/>
}
{
tab
===
"operations"
&&
<
OperationsTab
token=
{
state
.
token
}
/>
}
{
tab
===
"audit"
&&
<
AuditTab
token=
{
state
.
token
}
/>
}
</>
)
}
</
div
>
</
div
>
);
}
// ══════════════════════════════════════════════════════
// Settings Tab
// ══════════════════════════════════════════════════════
function
SettingsTab
({
config
,
setConfig
,
token
})
{
const
[
url
,
setUrl
]
=
useState
(
config
?.
gitlab_url
||
""
);
const
[
pat
,
setPat
]
=
useState
(
""
);
const
[
ns
,
setNs
]
=
useState
(
config
?.
default_namespace
||
""
);
const
[
saving
,
setSaving
]
=
useState
(
false
);
const
[
testing
,
setTesting
]
=
useState
(
false
);
const
[
msg
,
setMsg
]
=
useState
(
null
);
async
function
handleTest
()
{
if
(
!
url
||
!
pat
)
return
;
setTesting
(
true
);
setMsg
(
null
);
try
{
const
r
=
await
gitlabTestConnection
(
token
,
{
gitlab_url
:
url
,
access_token
:
pat
});
setMsg
(
r
.
ok
?
{
type
:
"success"
,
text
:
`Connected as
${
r
.
name
}
(@
${
r
.
user
}
)
${
r
.
is_admin
?
" [Admin]"
:
""
}
`
}
:
{
type
:
"error"
,
text
:
r
.
error
});
}
catch
(
e
)
{
setMsg
({
type
:
"error"
,
text
:
e
.
message
});
}
setTesting
(
false
);
}
async
function
handleSave
()
{
if
(
!
url
||
!
pat
)
return
;
setSaving
(
true
);
setMsg
(
null
);
try
{
const
r
=
await
gitlabSaveConfig
(
token
,
{
gitlab_url
:
url
,
access_token
:
pat
,
default_namespace
:
ns
||
null
});
setMsg
({
type
:
"success"
,
text
:
`Saved! Connected as
${
r
.
name
}
(@
${
r
.
user
}
)`
});
setConfig
({
configured
:
true
,
gitlab_url
:
url
});
}
catch
(
e
)
{
setMsg
({
type
:
"error"
,
text
:
e
.
message
});
}
setSaving
(
false
);
}
return
(
<
div
className=
"max-w-xl mx-auto space-y-6"
>
<
div
className=
"bg-anton-card border border-anton-border rounded-xl p-5 space-y-4"
>
<
h2
className=
"text-lg font-bold flex items-center gap-2"
><
Settings
size=
{
18
}
className=
"text-orange-400"
/>
GitLab Connection
</
h2
>
{
config
?.
configured
&&
<
p
className=
"text-xs text-green-400"
>
✓ Currently connected to
{
config
.
gitlab_url
}
(token:
{
config
.
token_masked
}
)
</
p
>
}
<
div
>
<
label
className=
"text-xs text-anton-muted block mb-1"
>
GitLab URL
</
label
>
<
input
value=
{
url
}
onChange=
{
(
e
)
=>
setUrl
(
e
.
target
.
value
)
}
placeholder=
"https://gitlab.yourdomain.com"
className=
"w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-orange-400"
/>
</
div
>
<
div
>
<
label
className=
"text-xs text-anton-muted block mb-1"
>
Personal Access Token
</
label
>
<
input
type=
"password"
value=
{
pat
}
onChange=
{
(
e
)
=>
setPat
(
e
.
target
.
value
)
}
placeholder=
"glpat-xxxxxxxxxxxxxxxxxxxx"
className=
"w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-orange-400"
/>
<
p
className=
"text-[10px] text-anton-muted mt-1"
>
Needs scopes: api, read_repository, write_repository
</
p
>
</
div
>
<
div
>
<
label
className=
"text-xs text-anton-muted block mb-1"
>
Default Namespace (optional)
</
label
>
<
input
value=
{
ns
}
onChange=
{
(
e
)
=>
setNs
(
e
.
target
.
value
)
}
placeholder=
"your-group"
className=
"w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-orange-400"
/>
</
div
>
{
msg
&&
(
<
div
className=
{
`text-sm p-3 rounded-lg ${msg.type === "success" ? "bg-green-500/10 text-green-400 border border-green-500/20" : "bg-red-500/10 text-red-400 border border-red-500/20"}`
}
>
{
msg
.
text
}
</
div
>
)
}
<
div
className=
"flex gap-2"
>
<
button
onClick=
{
handleTest
}
disabled=
{
testing
||
!
url
||
!
pat
}
className=
"px-4 py-2 bg-anton-card border border-anton-border rounded-lg text-sm hover:border-orange-400 disabled:opacity-30 flex items-center gap-1.5"
>
{
testing
?
<
Loader2
size=
{
14
}
className=
"animate-spin"
/>
:
<
Zap
size=
{
14
}
/>
}
Test
</
button
>
<
button
onClick=
{
handleSave
}
disabled=
{
saving
||
!
url
||
!
pat
}
className=
"px-4 py-2 bg-orange-500 rounded-lg text-sm text-white hover:bg-orange-600 disabled:opacity-30 flex items-center gap-1.5"
>
{
saving
?
<
Loader2
size=
{
14
}
className=
"animate-spin"
/>
:
<
Check
size=
{
14
}
/>
}
Save
&
Connect
</
button
>
</
div
>
</
div
>
</
div
>
);
}
// ══════════════════════════════════════════════════════
// Repos Tab
// ══════════════════════════════════════════════════════
function
ReposTab
({
config
,
token
})
{
const
[
projects
,
setProjects
]
=
useState
([]);
const
[
search
,
setSearch
]
=
useState
(
""
);
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
showCreate
,
setShowCreate
]
=
useState
(
false
);
const
[
newName
,
setNewName
]
=
useState
(
""
);
const
[
newDesc
,
setNewDesc
]
=
useState
(
""
);
const
[
creating
,
setCreating
]
=
useState
(
false
);
const
[
selectedProject
,
setSelectedProject
]
=
useState
(
null
);
const
loadProjects
=
useCallback
(
async
()
=>
{
setLoading
(
true
);
try
{
const
r
=
await
gitlabListProjects
(
token
,
search
);
setProjects
(
r
.
projects
||
[]);
}
catch
{
/* ignore */
}
setLoading
(
false
);
},
[
token
,
search
]);
useEffect
(()
=>
{
if
(
config
?.
configured
)
loadProjects
();
},
[
config
,
loadProjects
]);
async
function
handleCreate
()
{
if
(
!
newName
.
trim
())
return
;
setCreating
(
true
);
try
{
await
gitlabCreateProject
(
token
,
{
name
:
newName
,
description
:
newDesc
});
setNewName
(
""
);
setNewDesc
(
""
);
setShowCreate
(
false
);
loadProjects
();
}
catch
{
/* ignore */
}
setCreating
(
false
);
}
if
(
!
config
?.
configured
)
return
<
p
className=
"text-anton-muted text-center py-10"
>
Configure GitLab connection in Settings first.
</
p
>;
if
(
selectedProject
)
{
return
<
ProjectDetail
project=
{
selectedProject
}
token=
{
token
}
onBack=
{
()
=>
{
setSelectedProject
(
null
);
loadProjects
();
}
}
/>;
}
return
(
<
div
className=
"space-y-4"
>
<
div
className=
"flex items-center gap-2 flex-wrap"
>
<
input
value=
{
search
}
onChange=
{
(
e
)
=>
setSearch
(
e
.
target
.
value
)
}
onKeyDown=
{
(
e
)
=>
e
.
key
===
"Enter"
&&
loadProjects
()
}
placeholder=
"Search repos…"
className=
"flex-1 min-w-[200px] bg-anton-card border border-anton-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-400"
/>
<
button
onClick=
{
loadProjects
}
className=
"p-2 bg-anton-card border border-anton-border rounded-lg hover:border-orange-400"
><
RefreshCw
size=
{
16
}
/></
button
>
<
button
onClick=
{
()
=>
setShowCreate
(
!
showCreate
)
}
className=
"px-3 py-2 bg-orange-500 rounded-lg text-sm text-white flex items-center gap-1.5 hover:bg-orange-600"
><
Plus
size=
{
14
}
/>
New Repo
</
button
>
</
div
>
{
showCreate
&&
(
<
div
className=
"bg-anton-card border border-orange-500/30 rounded-xl p-4 space-y-3 animate-fade-in"
>
<
input
value=
{
newName
}
onChange=
{
(
e
)
=>
setNewName
(
e
.
target
.
value
)
}
placeholder=
"Repository name"
className=
"w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-400"
/>
<
input
value=
{
newDesc
}
onChange=
{
(
e
)
=>
setNewDesc
(
e
.
target
.
value
)
}
placeholder=
"Description (optional)"
className=
"w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-400"
/>
<
div
className=
"flex gap-2"
>
<
button
onClick=
{
handleCreate
}
disabled=
{
creating
||
!
newName
.
trim
()
}
className=
"px-4 py-2 bg-orange-500 rounded-lg text-sm text-white disabled:opacity-30 flex items-center gap-1.5"
>
{
creating
?
<
Loader2
size=
{
14
}
className=
"animate-spin"
/>
:
<
Plus
size=
{
14
}
/>
}
Create
</
button
>
<
button
onClick=
{
()
=>
setShowCreate
(
false
)
}
className=
"px-4 py-2 bg-anton-card border border-anton-border rounded-lg text-sm"
>
Cancel
</
button
>
</
div
>
</
div
>
)
}
{
loading
?
(
<
div
className=
"flex justify-center py-10"
><
Loader2
className=
"animate-spin text-anton-accent"
/></
div
>
)
:
(
<
div
className=
"grid gap-2"
>
{
projects
.
map
((
p
)
=>
(
<
button
key=
{
p
.
id
}
onClick=
{
()
=>
setSelectedProject
(
p
)
}
className=
"w-full text-left bg-anton-card border border-anton-border rounded-xl p-4 hover:border-orange-400/50 transition group"
>
<
div
className=
"flex items-start justify-between"
>
<
div
className=
"min-w-0"
>
<
div
className=
"font-semibold text-sm truncate group-hover:text-orange-400 transition"
>
{
p
.
path_with_namespace
||
p
.
name
}
</
div
>
{
p
.
description
&&
<
p
className=
"text-xs text-anton-muted mt-0.5 truncate"
>
{
p
.
description
}
</
p
>
}
</
div
>
<
ChevronRight
size=
{
16
}
className=
"text-anton-muted shrink-0 mt-0.5"
/>
</
div
>
<
div
className=
"flex gap-3 mt-2 text-[10px] text-anton-muted"
>
<
span
>
{
p
.
visibility
}
</
span
>
<
span
>
{
p
.
default_branch
||
"main"
}
</
span
>
{
p
.
last_activity_at
&&
<
span
>
{
new
Date
(
p
.
last_activity_at
).
toLocaleDateString
()
}
</
span
>
}
</
div
>
</
button
>
))
}
{
!
projects
.
length
&&
<
p
className=
"text-anton-muted text-center py-6 text-sm"
>
No repositories found.
</
p
>
}
</
div
>
)
}
</
div
>
);
}
// ══════════════════════════════════════════════════════
// Project Detail (File Browser + Actions)
// ══════════════════════════════════════════════════════
function
ProjectDetail
({
project
,
token
,
onBack
})
{
const
[
tree
,
setTree
]
=
useState
([]);
const
[
path
,
setPath
]
=
useState
(
""
);
const
[
branches
,
setBranches
]
=
useState
([]);
const
[
branch
,
setBranch
]
=
useState
(
project
.
default_branch
||
"main"
);
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
fileContent
,
setFileContent
]
=
useState
(
null
);
const
[
pipelines
,
setPipelines
]
=
useState
([]);
const
[
subTab
,
setSubTab
]
=
useState
(
"files"
);
const
loadTree
=
useCallback
(
async
()
=>
{
setLoading
(
true
);
try
{
const
t
=
await
gitlabGetTree
(
token
,
project
.
id
,
path
,
branch
);
setTree
(
t
.
sort
((
a
,
b
)
=>
(
a
.
type
===
"tree"
?
-
1
:
1
)
-
(
b
.
type
===
"tree"
?
-
1
:
1
)
||
a
.
name
.
localeCompare
(
b
.
name
)));
}
catch
{
setTree
([]);
}
setLoading
(
false
);
},
[
token
,
project
.
id
,
path
,
branch
]);
useEffect
(()
=>
{
loadTree
();
gitlabListBranches
(
token
,
project
.
id
).
then
(
setBranches
).
catch
(()
=>
{
});
},
[
loadTree
,
token
,
project
.
id
]);
async
function
openFile
(
filePath
)
{
try
{
const
f
=
await
gitlabGetFile
(
token
,
project
.
id
,
filePath
,
branch
);
setFileContent
({
...
f
,
path
:
filePath
});
}
catch
{
/* ignore */
}
}
async
function
loadPipelines
()
{
try
{
const
p
=
await
gitlabListPipelines
(
token
,
project
.
id
);
setPipelines
(
p
);
}
catch
{
setPipelines
([]);
}
}
return
(
<
div
className=
"space-y-3"
>
<
div
className=
"flex items-center gap-2 flex-wrap"
>
<
button
onClick=
{
onBack
}
className=
"p-1.5 rounded-lg hover:bg-anton-card text-anton-muted hover:text-white"
><
ArrowLeft
size=
{
16
}
/></
button
>
<
h2
className=
"font-bold text-sm"
>
{
project
.
path_with_namespace
}
</
h2
>
<
select
value=
{
branch
}
onChange=
{
(
e
)
=>
{
setBranch
(
e
.
target
.
value
);
setPath
(
""
);
setFileContent
(
null
);
}
}
className=
"ml-auto bg-anton-card border border-anton-border rounded-lg px-2 py-1 text-xs text-white"
>
{
branches
.
map
((
b
)
=>
<
option
key=
{
b
.
name
}
value=
{
b
.
name
}
>
{
b
.
name
}
</
option
>)
}
</
select
>
</
div
>
{
/* Sub-tabs */
}
<
div
className=
"flex gap-1 border-b border-anton-border pb-1"
>
{
[{
id
:
"files"
,
label
:
"Files"
,
icon
:
Folder
},
{
id
:
"pipelines"
,
label
:
"Pipelines"
,
icon
:
Rocket
}].
map
((
t
)
=>
(
<
button
key=
{
t
.
id
}
onClick=
{
()
=>
{
setSubTab
(
t
.
id
);
if
(
t
.
id
===
"pipelines"
)
loadPipelines
();
}
}
className=
{
`flex items-center gap-1 px-2.5 py-1.5 text-xs rounded-lg transition ${subTab === t.id ? "bg-orange-500/20 text-orange-400" : "text-anton-muted hover:text-white"}`
}
>
<
t
.
icon
size=
{
12
}
/>
{
t
.
label
}
</
button
>
))
}
</
div
>
{
subTab
===
"files"
&&
(
<>
{
/* Breadcrumb */
}
{
path
&&
(
<
div
className=
"flex items-center gap-1 text-xs text-anton-muted flex-wrap"
>
<
button
onClick=
{
()
=>
{
setPath
(
""
);
setFileContent
(
null
);
}
}
className=
"hover:text-orange-400"
>
{
project
.
name
}
</
button
>
{
path
.
split
(
"/"
).
map
((
seg
,
i
,
arr
)
=>
(
<
React
.
Fragment
key=
{
i
}
>
<
ChevronRight
size=
{
10
}
/>
<
button
onClick=
{
()
=>
{
setPath
(
arr
.
slice
(
0
,
i
+
1
).
join
(
"/"
));
setFileContent
(
null
);
}
}
className=
"hover:text-orange-400"
>
{
seg
}
</
button
>
</
React
.
Fragment
>
))
}
</
div
>
)
}
{
fileContent
?
(
<
div
className=
"bg-anton-card border border-anton-border rounded-xl overflow-hidden"
>
<
div
className=
"flex items-center justify-between px-3 py-2 border-b border-anton-border bg-anton-surface"
>
<
span
className=
"text-xs font-mono text-anton-muted"
>
{
fileContent
.
path
}
</
span
>
<
div
className=
"flex gap-1.5"
>
<
button
onClick=
{
()
=>
{
navigator
.
clipboard
.
writeText
(
fileContent
.
decoded_content
||
""
);
}
}
className=
"text-[10px] text-anton-muted hover:text-white flex items-center gap-1"
><
Copy
size=
{
10
}
/>
Copy
</
button
>
<
button
onClick=
{
()
=>
setFileContent
(
null
)
}
className=
"text-[10px] text-anton-muted hover:text-white"
><
X
size=
{
12
}
/></
button
>
</
div
>
</
div
>
<
pre
className=
"p-3 text-xs text-green-300 font-mono overflow-auto max-h-[60vh] whitespace-pre-wrap"
>
{
fileContent
.
decoded_content
||
"[Binary]"
}
</
pre
>
</
div
>
)
:
loading
?
(
<
div
className=
"flex justify-center py-10"
><
Loader2
className=
"animate-spin text-anton-accent"
/></
div
>
)
:
(
<
div
className=
"space-y-0.5"
>
{
tree
.
map
((
item
)
=>
(
<
button
key=
{
item
.
id
||
item
.
name
}
onClick=
{
()
=>
{
if
(
item
.
type
===
"tree"
)
{
setPath
(
item
.
path
);
}
else
{
openFile
(
item
.
path
);
}
}
}
className=
"w-full flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-anton-card transition text-left"
>
{
item
.
type
===
"tree"
?
<
Folder
size=
{
14
}
className=
"text-blue-400 shrink-0"
/>
:
<
File
size=
{
14
}
className=
"text-anton-muted shrink-0"
/>
}
<
span
className=
"text-sm truncate"
>
{
item
.
name
}
</
span
>
</
button
>
))
}
{
!
tree
.
length
&&
<
p
className=
"text-anton-muted text-center py-6 text-sm"
>
Empty directory.
</
p
>
}
</
div
>
)
}
</>
)
}
{
subTab
===
"pipelines"
&&
(
<
div
className=
"space-y-2"
>
<
button
onClick=
{
()
=>
gitlabTriggerPipeline
(
token
,
project
.
id
,
branch
).
then
(
loadPipelines
)
}
className=
"px-3 py-1.5 bg-green-600 rounded-lg text-xs text-white flex items-center gap-1.5 hover:bg-green-700"
>
<
Play
size=
{
12
}
/>
Run Pipeline
</
button
>
{
pipelines
.
map
((
p
)
=>
(
<
div
key=
{
p
.
id
}
className=
"bg-anton-card border border-anton-border rounded-lg px-3 py-2 flex items-center gap-3"
>
<
span
className=
{
`w-2 h-2 rounded-full shrink-0 ${p.status === "success" ? "bg-green-500" : p.status === "failed" ? "bg-red-500" : p.status === "running" ? "bg-blue-500 animate-pulse" : "bg-yellow-500"}`
}
/>
<
div
className=
"min-w-0 flex-1"
>
<
span
className=
"text-xs font-mono"
>
#
{
p
.
id
}
</
span
>
<
span
className=
"text-xs text-anton-muted ml-2"
>
{
p
.
ref
}
</
span
>
</
div
>
<
span
className=
"text-[10px] text-anton-muted"
>
{
p
.
status
}
</
span
>
</
div
>
))
}
{
!
pipelines
.
length
&&
<
p
className=
"text-anton-muted text-center py-6 text-sm"
>
No pipelines yet.
</
p
>
}
</
div
>
)
}
</
div
>
);
}
// ══════════════════════════════════════════════════════
// Operations Tab (THE APPROVAL QUEUE)
// ══════════════════════════════════════════════════════
function
OperationsTab
({
token
})
{
const
[
ops
,
setOps
]
=
useState
([]);
const
[
filter
,
setFilter
]
=
useState
(
"pending"
);
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
expandedOp
,
setExpandedOp
]
=
useState
(
null
);
const
load
=
useCallback
(
async
()
=>
{
setLoading
(
true
);
try
{
setOps
(
await
gitlabListOperations
(
token
,
filter
));
}
catch
{
setOps
([]);
}
setLoading
(
false
);
},
[
token
,
filter
]);
useEffect
(()
=>
{
load
();
},
[
load
]);
async
function
handleApprove
(
id
)
{
try
{
await
gitlabApproveOperation
(
token
,
id
);
load
();
}
catch
{
/* ignore */
}
}
async
function
handleReject
(
id
)
{
try
{
await
gitlabRejectOperation
(
token
,
id
);
load
();
}
catch
{
/* ignore */
}
}
return
(
<
div
className=
"space-y-4"
>
<
div
className=
"flex gap-1.5"
>
{
[
"pending"
,
"executed"
,
"failed"
,
"rejected"
,
"all"
].
map
((
f
)
=>
(
<
button
key=
{
f
}
onClick=
{
()
=>
setFilter
(
f
)
}
className=
{
`px-3 py-1.5 rounded-lg text-xs font-medium transition ${filter === f ? "bg-orange-500/20 text-orange-400" : "text-anton-muted hover:text-white hover:bg-anton-card"}`
}
>
{
f
.
charAt
(
0
).
toUpperCase
()
+
f
.
slice
(
1
)
}
</
button
>
))
}
<
button
onClick=
{
load
}
className=
"ml-auto p-1.5 hover:bg-anton-card rounded-lg text-anton-muted"
><
RefreshCw
size=
{
14
}
/></
button
>
</
div
>
{
loading
?
(
<
div
className=
"flex justify-center py-10"
><
Loader2
className=
"animate-spin text-anton-accent"
/></
div
>
)
:
(
<
div
className=
"space-y-2"
>
{
ops
.
map
((
op
)
=>
(
<
div
key=
{
op
.
id
}
className=
{
`bg-anton-card border rounded-xl overflow-hidden ${STATUS_STYLES[op.status] || "border-anton-border"}`
}
>
<
button
onClick=
{
()
=>
setExpandedOp
(
expandedOp
===
op
.
id
?
null
:
op
.
id
)
}
className=
"w-full px-4 py-3 flex items-center gap-3 text-left"
>
<
span
className=
{
`text-xs font-bold uppercase px-2 py-0.5 rounded ${STATUS_STYLES[op.status]}`
}
>
{
op
.
status
}
</
span
>
<
div
className=
"min-w-0 flex-1"
>
<
div
className=
"text-sm font-medium"
>
{
op
.
operation_type
.
replace
(
/_/g
,
" "
)
}
</
div
>
<
div
className=
"text-[10px] text-anton-muted"
>
{
op
.
project_name
||
`Project #${op.project_id}`
}
•
{
op
.
branch
||
"main"
}
•
{
new
Date
(
op
.
created_at
).
toLocaleString
()
}
</
div
>
</
div
>
{
expandedOp
===
op
.
id
?
<
ChevronDown
size=
{
14
}
/>
:
<
ChevronRight
size=
{
14
}
/>
}
</
button
>
{
expandedOp
===
op
.
id
&&
(
<
div
className=
"px-4 pb-3 border-t border-anton-border/50 pt-3 space-y-3 animate-fade-in"
>
<
div
className=
"bg-anton-bg rounded-lg p-3"
>
<
p
className=
"text-[10px] text-anton-muted mb-1 font-bold"
>
PAYLOAD
</
p
>
<
pre
className=
"text-xs font-mono text-green-300 whitespace-pre-wrap max-h-60 overflow-y-auto"
>
{
JSON
.
stringify
(
op
.
payload
,
null
,
2
)
}
</
pre
>
</
div
>
{
op
.
result
&&
(
<
div
className=
"bg-anton-bg rounded-lg p-3"
>
<
p
className=
"text-[10px] text-anton-muted mb-1 font-bold"
>
RESULT
</
p
>
<
pre
className=
"text-xs font-mono text-blue-300 whitespace-pre-wrap max-h-40 overflow-y-auto"
>
{
typeof
op
.
result
===
"string"
?
op
.
result
:
JSON
.
stringify
(
op
.
result
,
null
,
2
)
}
</
pre
>
</
div
>
)
}
{
op
.
status
===
"pending"
&&
(
<
div
className=
"flex gap-2"
>
<
button
onClick=
{
()
=>
handleApprove
(
op
.
id
)
}
className=
"px-4 py-2 bg-green-600 rounded-lg text-sm text-white flex items-center gap-1.5 hover:bg-green-700"
>
<
CheckCircle
size=
{
14
}
/>
Approve
&
Execute
</
button
>
<
button
onClick=
{
()
=>
handleReject
(
op
.
id
)
}
className=
"px-4 py-2 bg-red-600 rounded-lg text-sm text-white flex items-center gap-1.5 hover:bg-red-700"
>
<
XCircle
size=
{
14
}
/>
Reject
</
button
>
</
div
>
)
}
</
div
>
)
}
</
div
>
))
}
{
!
ops
.
length
&&
<
p
className=
"text-anton-muted text-center py-10 text-sm"
>
No operations found.
</
p
>
}
</
div
>
)
}
</
div
>
);
}
// ══════════════════════════════════════════════════════
// Audit Tab
// ══════════════════════════════════════════════════════
function
AuditTab
({
token
})
{
const
[
entries
,
setEntries
]
=
useState
([]);
const
[
page
,
setPage
]
=
useState
(
1
);
const
[
total
,
setTotal
]
=
useState
(
0
);
const
[
loading
,
setLoading
]
=
useState
(
false
);
useEffect
(()
=>
{
(
async
()
=>
{
setLoading
(
true
);
try
{
const
r
=
await
gitlabAuditLog
(
token
,
page
);
setEntries
(
r
.
entries
||
[]);
setTotal
(
r
.
total
||
0
);
}
catch
{
setEntries
([]);
}
setLoading
(
false
);
})();
},
[
token
,
page
]);
return
(
<
div
className=
"space-y-4"
>
<
h2
className=
"text-sm font-bold text-anton-muted"
>
{
total
}
total audit entries
</
h2
>
{
loading
?
(
<
div
className=
"flex justify-center py-10"
><
Loader2
className=
"animate-spin text-anton-accent"
/></
div
>
)
:
(
<
div
className=
"space-y-1"
>
{
entries
.
map
((
e
)
=>
(
<
div
key=
{
e
.
id
}
className=
"bg-anton-card border border-anton-border rounded-lg px-3 py-2 flex items-start gap-3"
>
<
Clock
size=
{
12
}
className=
"text-anton-muted mt-0.5 shrink-0"
/>
<
div
className=
"min-w-0 flex-1"
>
<
div
className=
"text-xs font-medium text-orange-400"
>
{
e
.
action
}
</
div
>
{
e
.
details
&&
<
p
className=
"text-[10px] text-anton-muted mt-0.5"
>
{
e
.
details
}
</
p
>
}
</
div
>
<
span
className=
"text-[10px] text-anton-muted whitespace-nowrap"
>
{
new
Date
(
e
.
created_at
).
toLocaleString
()
}
</
span
>
</
div
>
))
}
{
entries
.
length
===
50
&&
(
<
div
className=
"flex justify-center pt-2"
>
<
button
onClick=
{
()
=>
setPage
((
p
)
=>
p
+
1
)
}
className=
"text-xs text-orange-400 hover:underline"
>
Load more
</
button
>
</
div
>
)
}
</
div
>
)
}
</
div
>
);
}
\ No newline at end of file
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment