feat(deployments): docker tabanlı proje yönetim ve otomatik deploy sistemi ekle
Docker Compose projeleri için tam kapsamlı yönetim paneli ve otomatik deployment altyapısı eklendi. Sistem özellikleri: - Belirtilen root dizin altındaki docker-compose dosyası içeren projeleri tarama - Git repo bağlantısı ile branch yönetimi ve klonlama/pull işlemleri - Docker compose up/down komutları ile otomatik deploy - Gitea webhook entegrasyonu ile commit bazlı tetikleme - Deploy geçmişi, log kayıtları ve durum takibi (running/success/failed) - Deploy metrikleri ve dashboard görselleştirmesi - Webhook token ve secret yönetimi ile güvenlik - Proje favicon servisi Teknik değişiklikler: - Backend: deploymentProject, deploymentRun ve settings modelleri eklendi - Backend: deploymentService ile git ve docker işlemleri otomatize edildi - Backend: webhook doğrulaması için signature kontrolü eklendi - Docker: docker-cli ve docker-compose bağımlılıkları eklendi - Frontend: deployments ve settings sayfaları eklendi - Frontend: dashboard'a deploy metrikleri ve aktivite akışı eklendi - API: /api/deployments ve /api/settings yolları eklendi
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
VITE_API_URL=http://localhost:4000
|
||||
VITE_API_URL=http://localhost:4000/api
|
||||
# Prod için izin verilecek host(lar), virgülle ayırabilirsiniz. Örn:
|
||||
# ALLOWED_HOSTS=wisecolt-ci-frontend-ft2pzo-1c0eb3-188-245-185-248.traefik.me
|
||||
|
||||
@@ -5,6 +5,9 @@ import { DashboardLayout } from "./components/DashboardLayout";
|
||||
import { HomePage } from "./pages/HomePage";
|
||||
import { JobsPage } from "./pages/JobsPage";
|
||||
import { JobDetailPage } from "./pages/JobDetailPage";
|
||||
import { DeploymentsPage } from "./pages/DeploymentsPage";
|
||||
import { DeploymentDetailPage } from "./pages/DeploymentDetailPage";
|
||||
import { SettingsPage } from "./pages/SettingsPage";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -15,6 +18,9 @@ function App() {
|
||||
<Route path="/home" element={<HomePage />} />
|
||||
<Route path="/jobs" element={<JobsPage />} />
|
||||
<Route path="/jobs/:id" element={<JobDetailPage />} />
|
||||
<Route path="/deployments" element={<DeploymentsPage />} />
|
||||
<Route path="/deployments/:id" element={<DeploymentDetailPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="*" element={<Navigate to="/home" replace />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
115
frontend/src/api/deployments.ts
Normal file
115
frontend/src/api/deployments.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export type ComposeFile = "docker-compose.yml" | "docker-compose.dev.yml";
|
||||
export type DeploymentStatus = "idle" | "running" | "success" | "failed";
|
||||
export type DeploymentEnv = "dev" | "prod";
|
||||
|
||||
export interface DeploymentProject {
|
||||
_id: string;
|
||||
name: string;
|
||||
rootPath: string;
|
||||
repoUrl: string;
|
||||
branch: string;
|
||||
composeFile: ComposeFile;
|
||||
webhookToken: string;
|
||||
env: DeploymentEnv;
|
||||
port?: number;
|
||||
lastDeployAt?: string;
|
||||
lastStatus: DeploymentStatus;
|
||||
lastMessage?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DeploymentRun {
|
||||
_id: string;
|
||||
project: string;
|
||||
status: "running" | "success" | "failed";
|
||||
message?: string;
|
||||
logs: string[];
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
durationMs?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DeploymentRunWithProject extends Omit<DeploymentRun, "project"> {
|
||||
project: DeploymentProject;
|
||||
}
|
||||
|
||||
export interface DeploymentDetailResponse {
|
||||
project: DeploymentProject;
|
||||
runs: DeploymentRun[];
|
||||
}
|
||||
|
||||
export interface DeploymentMetrics {
|
||||
dailyStats: Array<{
|
||||
_id: string;
|
||||
total: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
avgDurationMs?: number;
|
||||
}>;
|
||||
recentRuns: DeploymentRunWithProject[];
|
||||
}
|
||||
|
||||
export interface DeploymentCandidate {
|
||||
name: string;
|
||||
rootPath: string;
|
||||
composeFiles: ComposeFile[];
|
||||
}
|
||||
|
||||
export interface DeploymentInput {
|
||||
name: string;
|
||||
rootPath: string;
|
||||
repoUrl: string;
|
||||
branch: string;
|
||||
composeFile: ComposeFile;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
export async function fetchDeployments(): Promise<DeploymentProject[]> {
|
||||
const { data } = await apiClient.get("/deployments");
|
||||
return data as DeploymentProject[];
|
||||
}
|
||||
|
||||
export async function fetchDeploymentBranches(repoUrl: string): Promise<string[]> {
|
||||
const { data } = await apiClient.get("/deployments/branches", {
|
||||
params: { repoUrl }
|
||||
});
|
||||
return (data as { branches: string[] }).branches;
|
||||
}
|
||||
|
||||
export async function scanDeployments(): Promise<DeploymentCandidate[]> {
|
||||
const { data } = await apiClient.get("/deployments/scan");
|
||||
return data as DeploymentCandidate[];
|
||||
}
|
||||
|
||||
export async function createDeployment(payload: DeploymentInput): Promise<DeploymentProject> {
|
||||
const { data } = await apiClient.post("/deployments", payload);
|
||||
return data as DeploymentProject;
|
||||
}
|
||||
|
||||
export async function updateDeployment(id: string, payload: Omit<DeploymentInput, "rootPath">) {
|
||||
const { data } = await apiClient.put(`/deployments/${id}`, payload);
|
||||
return data as DeploymentProject;
|
||||
}
|
||||
|
||||
export async function deleteDeployment(id: string): Promise<void> {
|
||||
await apiClient.delete(`/deployments/${id}`);
|
||||
}
|
||||
|
||||
export async function runDeployment(id: string): Promise<void> {
|
||||
await apiClient.post(`/deployments/${id}/run`);
|
||||
}
|
||||
|
||||
export async function fetchDeployment(id: string): Promise<DeploymentDetailResponse> {
|
||||
const { data } = await apiClient.get(`/deployments/${id}`);
|
||||
return data as DeploymentDetailResponse;
|
||||
}
|
||||
|
||||
export async function fetchDeploymentMetrics(): Promise<DeploymentMetrics> {
|
||||
const { data } = await apiClient.get("/deployments/metrics/summary");
|
||||
return data as DeploymentMetrics;
|
||||
}
|
||||
22
frontend/src/api/settings.ts
Normal file
22
frontend/src/api/settings.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export interface SettingsResponse {
|
||||
webhookToken: string;
|
||||
webhookSecret: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export async function fetchSettings(): Promise<SettingsResponse> {
|
||||
const { data } = await apiClient.get("/settings");
|
||||
return data as SettingsResponse;
|
||||
}
|
||||
|
||||
export async function rotateWebhookToken(): Promise<SettingsResponse> {
|
||||
const { data } = await apiClient.post("/settings/token/rotate");
|
||||
return data as SettingsResponse;
|
||||
}
|
||||
|
||||
export async function rotateWebhookSecret(): Promise<SettingsResponse> {
|
||||
const { data } = await apiClient.post("/settings/secret/rotate");
|
||||
return data as SettingsResponse;
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faHouse, faBriefcase, faArrowRightFromBracket, faUser, faFlaskVial } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
faHouse,
|
||||
faArrowRightFromBracket,
|
||||
faUser,
|
||||
faFlaskVial,
|
||||
faRocket,
|
||||
faGear
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { Button } from "./ui/button";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
import { useAuth } from "../providers/auth-provider";
|
||||
@@ -15,7 +22,9 @@ export function DashboardLayout() {
|
||||
const navigation = useMemo(
|
||||
() => [
|
||||
{ label: "Home", to: "/home", icon: faHouse },
|
||||
{ label: "Jobs", to: "/jobs", icon: faFlaskVial }
|
||||
{ label: "Jobs", to: "/jobs", icon: faFlaskVial },
|
||||
{ label: "Deployments", to: "/deployments", icon: faRocket },
|
||||
{ label: "Settings", to: "/settings", icon: faGear }
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
221
frontend/src/pages/DeploymentDetailPage.tsx
Normal file
221
frontend/src/pages/DeploymentDetailPage.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faArrowLeft, faCloudArrowUp, faCopy, faHistory } from "@fortawesome/free-solid-svg-icons";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
||||
import { JobStatusBadge } from "../components/JobStatusBadge";
|
||||
import { DeploymentProject, DeploymentRun, fetchDeployment, runDeployment } from "../api/deployments";
|
||||
|
||||
export function DeploymentDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [project, setProject] = useState<DeploymentProject | null>(null);
|
||||
const [runs, setRuns] = useState<DeploymentRun[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [triggering, setTriggering] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
fetchDeployment(id)
|
||||
.then((data) => {
|
||||
setProject(data.project);
|
||||
setRuns(data.runs);
|
||||
})
|
||||
.catch(() => toast.error("Deployment bulunamadı"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
const webhookUrl = useMemo(() => {
|
||||
if (!project) return "";
|
||||
return `${window.location.origin}/api/deployments/webhook/${project.webhookToken}`;
|
||||
}, [project]);
|
||||
|
||||
const latestRun = runs[0];
|
||||
|
||||
const decorateLogLine = (line: string) => {
|
||||
const lower = line.toLowerCase();
|
||||
if (lower.includes("error") || lower.includes("fail") || lower.includes("hata")) {
|
||||
return `❌ ${line}`;
|
||||
}
|
||||
if (lower.includes("success") || lower.includes("başarılı") || lower.includes("completed")) {
|
||||
return `✅ ${line}`;
|
||||
}
|
||||
if (lower.includes("docker")) {
|
||||
return `🐳 ${line}`;
|
||||
}
|
||||
if (lower.includes("git")) {
|
||||
return `🔧 ${line}`;
|
||||
}
|
||||
if (lower.includes("clone") || lower.includes("pull") || lower.includes("fetch")) {
|
||||
return `📦 ${line}`;
|
||||
}
|
||||
return `• ${line}`;
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(webhookUrl);
|
||||
toast.success("Webhook URL kopyalandı");
|
||||
} catch {
|
||||
toast.error("Webhook URL kopyalanamadı");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRun = async () => {
|
||||
if (!id) return;
|
||||
setTriggering(true);
|
||||
try {
|
||||
await runDeployment(id);
|
||||
toast.success("Deploy tetiklendi");
|
||||
} catch {
|
||||
toast.error("Deploy tetiklenemedi");
|
||||
} finally {
|
||||
setTriggering(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
|
||||
Deployment yükleniyor...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
|
||||
Deployment bulunamadı.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate("/deployments")}>
|
||||
<FontAwesomeIcon icon={faArrowLeft} />
|
||||
</Button>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground">{project.name}</h2>
|
||||
<div className="text-sm text-muted-foreground">{project.rootPath}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate("/deployments", { state: { editDeploymentId: project._id } })}
|
||||
>
|
||||
Düzenle
|
||||
</Button>
|
||||
<Button onClick={handleRun} disabled={triggering} className="gap-2">
|
||||
<FontAwesomeIcon icon={faCloudArrowUp} className="h-4 w-4" />
|
||||
{triggering ? "Deploying..." : "Deploy"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Genel Bilgiler</CardTitle>
|
||||
<JobStatusBadge status={project.lastStatus} />
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="font-medium text-foreground">Repo:</span>
|
||||
<span className="text-foreground/80">{project.repoUrl}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="font-medium text-foreground">Branch:</span>
|
||||
<span className="text-foreground/80">{project.branch}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="font-medium text-foreground">Compose:</span>
|
||||
<span className="text-foreground/80">{project.composeFile}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="font-medium text-foreground">Env:</span>
|
||||
<span className="rounded-md bg-muted px-2 py-1 text-xs font-semibold text-foreground/80">
|
||||
{project.env.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="font-medium text-foreground">Last Deploy:</span>
|
||||
<span className="text-foreground/80">
|
||||
{project.lastDeployAt ? new Date(project.lastDeployAt).toLocaleString() : "-"}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Webhook URL</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between gap-3 rounded-md bg-muted px-3 py-2 text-sm">
|
||||
<code className="break-all text-foreground/80">{webhookUrl}</code>
|
||||
<Button variant="ghost" size="icon" onClick={handleCopy} title="Kopyala">
|
||||
<FontAwesomeIcon icon={faCopy} className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faHistory} className="h-4 w-4" />
|
||||
Deploy Geçmişi
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{runs.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground">Henüz deploy çalıştırılmadı.</div>
|
||||
)}
|
||||
{runs.map((run) => (
|
||||
<div
|
||||
key={run._id}
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<JobStatusBadge status={run.status} />
|
||||
<span className="text-muted-foreground">
|
||||
{new Date(run.startedAt).toLocaleString()}
|
||||
</span>
|
||||
{run.message && (
|
||||
<span className="truncate text-foreground/80">· {run.message}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{run.durationMs ? `${Math.round(run.durationMs / 1000)}s` : "-"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Son Deploy Logları</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-h-72 overflow-auto rounded-md border border-border bg-black px-3 py-2 font-mono text-xs text-green-100">
|
||||
{latestRun?.logs?.length ? (
|
||||
latestRun.logs.map((line, idx) => (
|
||||
<div key={idx} className="whitespace-pre-wrap">
|
||||
{decorateLogLine(line)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-muted-foreground">Henüz log yok.</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
532
frontend/src/pages/DeploymentsPage.tsx
Normal file
532
frontend/src/pages/DeploymentsPage.tsx
Normal file
@@ -0,0 +1,532 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faCloudArrowUp,
|
||||
faPlus,
|
||||
faRotate,
|
||||
faRocket
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { Card, CardContent } from "../components/ui/card";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Label } from "../components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
|
||||
import {
|
||||
createDeployment,
|
||||
deleteDeployment,
|
||||
DeploymentCandidate,
|
||||
DeploymentInput,
|
||||
DeploymentProject,
|
||||
fetchDeploymentBranches,
|
||||
fetchDeployments,
|
||||
runDeployment,
|
||||
scanDeployments,
|
||||
updateDeployment
|
||||
} from "../api/deployments";
|
||||
import { JobStatusBadge } from "../components/JobStatusBadge";
|
||||
|
||||
type FormState = {
|
||||
_id?: string;
|
||||
name: string;
|
||||
rootPath: string;
|
||||
repoUrl: string;
|
||||
branch: string;
|
||||
composeFile: DeploymentInput["composeFile"];
|
||||
port: string;
|
||||
};
|
||||
|
||||
const defaultForm: FormState = {
|
||||
name: "",
|
||||
rootPath: "",
|
||||
repoUrl: "",
|
||||
branch: "main",
|
||||
composeFile: "docker-compose.yml",
|
||||
port: ""
|
||||
};
|
||||
|
||||
export function DeploymentsPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const apiBase = (import.meta.env.VITE_API_URL || "").replace(/\/$/, "");
|
||||
const [deployments, setDeployments] = useState<DeploymentProject[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [candidates, setCandidates] = useState<DeploymentCandidate[]>([]);
|
||||
const [form, setForm] = useState<FormState>(defaultForm);
|
||||
const [pendingEditId, setPendingEditId] = useState<string | null>(null);
|
||||
const [branchOptions, setBranchOptions] = useState<string[]>([]);
|
||||
const [branchLoading, setBranchLoading] = useState(false);
|
||||
const [faviconErrors, setFaviconErrors] = useState<Record<string, boolean>>({});
|
||||
|
||||
const isEdit = useMemo(() => !!form._id, [form._id]);
|
||||
const selectedCandidate = useMemo(
|
||||
() => candidates.find((c) => c.rootPath === form.rootPath),
|
||||
[candidates, form.rootPath]
|
||||
);
|
||||
|
||||
const loadDeployments = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchDeployments();
|
||||
setDeployments(data);
|
||||
} catch {
|
||||
toast.error("Deployment listesi alınamadı");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCandidates = async () => {
|
||||
setScanning(true);
|
||||
try {
|
||||
const data = await scanDeployments();
|
||||
setCandidates(data);
|
||||
} catch {
|
||||
toast.error("Root taraması yapılamadı");
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadDeployments();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const repoUrl = form.repoUrl.trim();
|
||||
if (!repoUrl) {
|
||||
setBranchOptions([]);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(async () => {
|
||||
setBranchLoading(true);
|
||||
try {
|
||||
const branches = await fetchDeploymentBranches(repoUrl);
|
||||
setBranchOptions(branches);
|
||||
if (!form.branch && branches.length > 0) {
|
||||
setForm((prev) => ({ ...prev, branch: branches.includes("main") ? "main" : branches[0] }));
|
||||
}
|
||||
} catch {
|
||||
setBranchOptions([]);
|
||||
} finally {
|
||||
setBranchLoading(false);
|
||||
}
|
||||
}, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [form.repoUrl, form.branch]);
|
||||
|
||||
useEffect(() => {
|
||||
const state = location.state as { editDeploymentId?: string } | null;
|
||||
if (state?.editDeploymentId) {
|
||||
setPendingEditId(state.editDeploymentId);
|
||||
navigate(location.pathname, { replace: true });
|
||||
}
|
||||
}, [location.state, navigate, location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingEditId || deployments.length === 0) return;
|
||||
const deployment = deployments.find((d) => d._id === pendingEditId);
|
||||
if (deployment) {
|
||||
handleEdit(deployment);
|
||||
setPendingEditId(null);
|
||||
}
|
||||
}, [pendingEditId, deployments]);
|
||||
|
||||
const handleOpenNew = async () => {
|
||||
setForm(defaultForm);
|
||||
setBranchOptions([]);
|
||||
setModalOpen(true);
|
||||
await loadCandidates();
|
||||
};
|
||||
|
||||
const handleEdit = (deployment: DeploymentProject) => {
|
||||
const { _id, name, rootPath, repoUrl, branch, composeFile, port } = deployment;
|
||||
setForm({
|
||||
_id,
|
||||
name,
|
||||
rootPath,
|
||||
repoUrl,
|
||||
branch,
|
||||
composeFile,
|
||||
port: port ? String(port) : ""
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: DeploymentInput = {
|
||||
name: form.name,
|
||||
rootPath: form.rootPath,
|
||||
repoUrl: form.repoUrl,
|
||||
branch: form.branch,
|
||||
composeFile: form.composeFile,
|
||||
port: form.port ? Number(form.port) : undefined
|
||||
};
|
||||
|
||||
if (!payload.name || !payload.rootPath || !payload.repoUrl || !payload.branch || !payload.composeFile) {
|
||||
toast.error("Tüm alanları doldurun");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEdit && form._id) {
|
||||
const updated = await updateDeployment(form._id, {
|
||||
name: payload.name,
|
||||
repoUrl: payload.repoUrl,
|
||||
branch: payload.branch,
|
||||
composeFile: payload.composeFile,
|
||||
port: payload.port
|
||||
});
|
||||
setDeployments((prev) => prev.map((d) => (d._id === updated._id ? updated : d)));
|
||||
toast.success("Deployment güncellendi");
|
||||
} else {
|
||||
const created = await createDeployment(payload);
|
||||
setDeployments((prev) => [created, ...prev]);
|
||||
toast.success("Deployment oluşturuldu");
|
||||
}
|
||||
|
||||
setModalOpen(false);
|
||||
} catch {
|
||||
toast.error("İşlem sırasında hata oluştu");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRun = async (id: string) => {
|
||||
try {
|
||||
await runDeployment(id);
|
||||
toast.success("Deploy tetiklendi");
|
||||
} catch {
|
||||
toast.error("Deploy tetiklenemedi");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (deployment: DeploymentProject) => {
|
||||
const ok = window.confirm("Bu deployment'ı silmek istediğinize emin misiniz?");
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteDeployment(deployment._id);
|
||||
setDeployments((prev) => prev.filter((d) => d._id !== deployment._id));
|
||||
toast.success("Deployment silindi");
|
||||
} catch {
|
||||
toast.error("Deployment silinemedi");
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (value?: string) => {
|
||||
if (!value) return "-";
|
||||
return new Date(value).toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground">Deployments</h2>
|
||||
</div>
|
||||
<Button onClick={handleOpenNew} className="gap-2">
|
||||
<FontAwesomeIcon icon={faPlus} className="h-4 w-4" />
|
||||
New Deployment
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{loading && (
|
||||
<div className="rounded-md border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
||||
Deployments yükleniyor...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && deployments.length === 0 && (
|
||||
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
|
||||
Henüz deployment eklenmemiş.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deployments.map((deployment) => {
|
||||
const faviconUrl = apiBase
|
||||
? `${apiBase}/deployments/${deployment._id}/favicon`
|
||||
: `/api/deployments/${deployment._id}/favicon`;
|
||||
return (
|
||||
<Card
|
||||
key={deployment._id}
|
||||
className="cursor-pointer transition hover:border-primary/50"
|
||||
onClick={() => navigate(`/deployments/${deployment._id}`)}
|
||||
>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
{!faviconErrors[deployment._id] ? (
|
||||
<img
|
||||
src={faviconUrl}
|
||||
alt={`${deployment.name} favicon`}
|
||||
className="h-4 w-4"
|
||||
onError={() =>
|
||||
setFaviconErrors((prev) => ({ ...prev, [deployment._id]: true }))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faRocket} className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold text-foreground">{deployment.name}</div>
|
||||
<div className="text-sm text-muted-foreground">{deployment.rootPath}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
||||
<JobStatusBadge status={deployment.lastStatus} />
|
||||
<span className="rounded-md bg-muted px-2 py-1 text-xs font-semibold text-foreground/80">
|
||||
{deployment.env.toUpperCase()}
|
||||
</span>
|
||||
<span className="rounded-md bg-muted px-2 py-1 text-xs font-semibold text-foreground/80">
|
||||
{deployment.composeFile}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRun(deployment._id);
|
||||
}}
|
||||
title="Deploy tetikle"
|
||||
>
|
||||
<FontAwesomeIcon icon={faCloudArrowUp} className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(deployment);
|
||||
}}
|
||||
title="Düzenle"
|
||||
>
|
||||
<FontAwesomeIcon icon={faRotate} className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(deployment);
|
||||
}}
|
||||
title="Sil"
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-1 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-foreground">Repo:</span>
|
||||
<span className="truncate text-foreground/80">{deployment.repoUrl}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-foreground">Branch:</span>
|
||||
<span className="text-foreground/80">{deployment.branch}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-foreground">Last Deploy:</span>
|
||||
<span className="text-foreground/80">{formatDate(deployment.lastDeployAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{modalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 py-8">
|
||||
<div className="w-full max-w-lg overflow-hidden rounded-lg border border-border bg-card card-shadow">
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||
<div className="space-y-1">
|
||||
<div className="text-lg font-semibold text-foreground">
|
||||
{isEdit ? "Deployment Güncelle" : "Yeni Deployment"}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Repo ve branch seçimi sonrası webhook tetiklemeleriyle deploy yapılır.
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={handleClose}>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[70vh] space-y-4 overflow-y-auto px-5 py-4">
|
||||
{!isEdit && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Proje Klasörü</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadCandidates}
|
||||
disabled={scanning}
|
||||
>
|
||||
{scanning ? "Taranıyor..." : "Yeniden Tara"}
|
||||
</Button>
|
||||
</div>
|
||||
<Select
|
||||
value={form.rootPath}
|
||||
onValueChange={(value) => {
|
||||
const candidate = candidates.find((c) => c.rootPath === value);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
rootPath: value,
|
||||
name: candidate?.name || prev.name,
|
||||
composeFile: candidate?.composeFiles[0] || prev.composeFile
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Root altında proje seçin" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{candidates.map((candidate) => (
|
||||
<SelectItem key={candidate.rootPath} value={candidate.rootPath}>
|
||||
{candidate.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{scanning
|
||||
? "Root dizin taranıyor..."
|
||||
: candidates.length === 0
|
||||
? "Root altında compose dosyası bulunan proje yok."
|
||||
: "Compose dosyası bulunan klasörleri listeler."}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="repo">Repo URL</Label>
|
||||
<Input
|
||||
id="repo"
|
||||
value={form.repoUrl}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, repoUrl: e.target.value }))}
|
||||
placeholder="https://gitea.example.com/org/repo"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Deployment Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="wisecolt-app"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="branch">Branch</Label>
|
||||
{branchOptions.length > 0 ? (
|
||||
<Select
|
||||
value={form.branch}
|
||||
onValueChange={(value) => setForm((prev) => ({ ...prev, branch: value }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Branch seçin" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{branchOptions.map((branch) => (
|
||||
<SelectItem key={branch} value={branch}>
|
||||
{branch}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
id="branch"
|
||||
value={form.branch}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, branch: e.target.value }))}
|
||||
placeholder="main"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{branchLoading
|
||||
? "Branch listesi alınıyor..."
|
||||
: branchOptions.length > 0
|
||||
? "Repo üzerindeki branch'lar listelendi."
|
||||
: "Repo URL girildiğinde branch listesi otomatik gelir."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Compose Dosyası</Label>
|
||||
<Select
|
||||
value={form.composeFile}
|
||||
onValueChange={(value) =>
|
||||
setForm((prev) => ({ ...prev, composeFile: value as DeploymentInput["composeFile"] }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Compose seçin" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(selectedCandidate?.composeFiles || ["docker-compose.yml", "docker-compose.dev.yml"]).map(
|
||||
(file) => (
|
||||
<SelectItem key={file} value={file}>
|
||||
{file}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="port">Port (opsiyonel)</Label>
|
||||
<Input
|
||||
id="port"
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.port}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, port: e.target.value }))}
|
||||
placeholder="3000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 border-t border-border px-5 py-4">
|
||||
<Button variant="ghost" onClick={handleClose} disabled={saving}>
|
||||
İptal
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? "Kaydediliyor..." : "Kaydet"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -13,10 +13,11 @@ import {
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
|
||||
import { useLiveData } from "../providers/live-provider";
|
||||
import { fetchJobMetrics, JobMetrics } from "../api/jobs";
|
||||
import { fetchDeploymentMetrics, DeploymentMetrics, DeploymentRunWithProject } from "../api/deployments";
|
||||
import { JobStatusBadge } from "../components/JobStatusBadge";
|
||||
import { RepoIcon } from "../components/RepoIcon";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faClockRotateLeft, faListCheck } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faClockRotateLeft, faListCheck, faFlaskVial, faRocket } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
function formatDuration(ms?: number) {
|
||||
if (!ms || Number.isNaN(ms)) return "-";
|
||||
@@ -29,28 +30,79 @@ function formatDuration(ms?: number) {
|
||||
return `${hours}sa ${minutes % 60}dk`;
|
||||
}
|
||||
|
||||
function toYmd(date: Date) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
const [metrics, setMetrics] = useState<JobMetrics | null>(null);
|
||||
const [deploymentMetrics, setDeploymentMetrics] = useState<DeploymentMetrics | null>(null);
|
||||
const [deployRuns, setDeployRuns] = useState<DeploymentRunWithProject[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { jobStreams } = useLiveData();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
fetchJobMetrics()
|
||||
.then(setMetrics)
|
||||
.catch(() => setError("Metrikler alınamadı"))
|
||||
Promise.allSettled([fetchJobMetrics(), fetchDeploymentMetrics()])
|
||||
.then(([jobResult, deployResult]) => {
|
||||
if (jobResult.status === "fulfilled") {
|
||||
setMetrics(jobResult.value);
|
||||
} else {
|
||||
setMetrics({
|
||||
dailyStats: [],
|
||||
recentRuns: [],
|
||||
totals: { successRate: 0, totalRuns: 0 }
|
||||
});
|
||||
setError("Job metrikleri alınamadı");
|
||||
}
|
||||
|
||||
if (deployResult.status === "fulfilled") {
|
||||
setDeploymentMetrics(deployResult.value);
|
||||
setDeployRuns(deployResult.value.recentRuns || []);
|
||||
} else {
|
||||
setDeploymentMetrics({ dailyStats: [], recentRuns: [] });
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!metrics) return [];
|
||||
return metrics.dailyStats.map((d) => ({
|
||||
date: d._id,
|
||||
Başarılı: d.success,
|
||||
Hatalı: d.failed
|
||||
}));
|
||||
}, [metrics]);
|
||||
if (!metrics) {
|
||||
const days = Array.from({ length: 7 }).map((_, idx) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - (6 - idx));
|
||||
return toYmd(date);
|
||||
});
|
||||
return days.map((date) => ({
|
||||
date,
|
||||
"Test Başarılı": 0,
|
||||
"Test Hatalı": 0,
|
||||
"Deploy Başarılı": 0,
|
||||
"Deploy Hatalı": 0
|
||||
}));
|
||||
}
|
||||
const deployMap = new Map((deploymentMetrics?.dailyStats || []).map((d) => [d._id, d]));
|
||||
const jobMap = new Map(metrics.dailyStats.map((d) => [d._id, d]));
|
||||
|
||||
const days = Array.from({ length: 7 }).map((_, idx) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - (6 - idx));
|
||||
return toYmd(date);
|
||||
});
|
||||
|
||||
return days.map((date) => {
|
||||
const job = jobMap.get(date);
|
||||
const deploy = deployMap.get(date);
|
||||
return {
|
||||
date,
|
||||
"Test Başarılı": job?.success || 0,
|
||||
"Test Hatalı": job?.failed || 0,
|
||||
"Deploy Başarılı": deploy?.success || 0,
|
||||
"Deploy Hatalı": deploy?.failed || 0
|
||||
};
|
||||
});
|
||||
}, [metrics, deploymentMetrics]);
|
||||
|
||||
const mergedRuns = useMemo(() => {
|
||||
if (!metrics) return [];
|
||||
@@ -69,6 +121,35 @@ export function HomePage() {
|
||||
});
|
||||
}, [metrics, jobStreams]);
|
||||
|
||||
const activityItems = useMemo(() => {
|
||||
const jobItems = mergedRuns.map((run) => ({
|
||||
id: run._id,
|
||||
type: "test" as const,
|
||||
title: run.job.name,
|
||||
repoUrl: run.job.repoUrl,
|
||||
status: run.status,
|
||||
startedAt: run.startedAt,
|
||||
durationMs: run.durationMs,
|
||||
link: `/jobs/${run.job._id}`
|
||||
}));
|
||||
|
||||
const deployItems = deployRuns.map((run) => ({
|
||||
id: run._id,
|
||||
type: "deploy" as const,
|
||||
title: run.project.name,
|
||||
repoUrl: run.project.repoUrl,
|
||||
status: run.status,
|
||||
startedAt: run.startedAt,
|
||||
durationMs: run.durationMs,
|
||||
message: run.message,
|
||||
link: `/deployments/${run.project._id}`
|
||||
}));
|
||||
|
||||
return [...jobItems, ...deployItems]
|
||||
.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime())
|
||||
.slice(0, 10);
|
||||
}, [mergedRuns, deployRuns]);
|
||||
|
||||
const lastRunDuration = useMemo(() => formatDuration(mergedRuns[0]?.durationMs), [mergedRuns]);
|
||||
|
||||
return (
|
||||
@@ -78,14 +159,14 @@ export function HomePage() {
|
||||
<CardHeader className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Son 7 Gün Çalıştırma Trendleri</CardTitle>
|
||||
<CardDescription>Başarılı / Hatalı job sayıları</CardDescription>
|
||||
<CardDescription>Test ve Deploy sonuçları</CardDescription>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faClockRotateLeft} className="h-3.5 w-3.5" />
|
||||
{metrics?.totals.totalRuns ?? 0} toplam koşu
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="h-80 min-w-0">
|
||||
<CardContent className="h-48 min-w-0">
|
||||
{loading ? (
|
||||
<div className="text-sm text-muted-foreground">Yükleniyor...</div>
|
||||
) : chartData.length === 0 ? (
|
||||
@@ -96,10 +177,24 @@ export function HomePage() {
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis allowDecimals={false} />
|
||||
<Tooltip />
|
||||
<Tooltip
|
||||
wrapperStyle={{ zIndex: 50 }}
|
||||
contentStyle={{
|
||||
backgroundColor: "white",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "8px",
|
||||
boxShadow: "0 10px 20px rgba(0,0,0,0.12)",
|
||||
color: "#111827",
|
||||
opacity: 1
|
||||
}}
|
||||
labelStyle={{ color: "#111827", fontWeight: 600 }}
|
||||
itemStyle={{ color: "#111827" }}
|
||||
/>
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey="Başarılı" stroke="#10b981" strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="Hatalı" stroke="#ef4444" strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="Test Başarılı" stroke="#10b981" strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="Test Hatalı" stroke="#ef4444" strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="Deploy Başarılı" stroke="#f59e0b" strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="Deploy Hatalı" stroke="#f97316" strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
@@ -111,7 +206,7 @@ export function HomePage() {
|
||||
<CardTitle>Hızlı Metrikler</CardTitle>
|
||||
<CardDescription>Özet görünüm</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
||||
<CardContent className="flex h-48 flex-col justify-center space-y-3 text-sm text-muted-foreground">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Başarı Oranı</span>
|
||||
<span className="text-lg font-semibold text-foreground">
|
||||
@@ -136,33 +231,50 @@ export function HomePage() {
|
||||
<CardHeader className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Etkinlik Akışı</CardTitle>
|
||||
<CardDescription>Son 10 job çalıştırması</CardDescription>
|
||||
<CardDescription>Son 10 aktivite</CardDescription>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<FontAwesomeIcon icon={faListCheck} className="h-3.5 w-3.5" />
|
||||
{mergedRuns.length ?? 0} kayıt
|
||||
{activityItems.length ?? 0} kayıt
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{loading && <div className="text-sm text-muted-foreground">Yükleniyor...</div>}
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
{!loading && mergedRuns.length === 0 && (
|
||||
{!loading && activityItems.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground">Henüz çalıştırma yok.</div>
|
||||
)}
|
||||
{!loading &&
|
||||
mergedRuns.map((run) => (
|
||||
activityItems.map((run) => (
|
||||
<button
|
||||
key={run._id}
|
||||
key={run.id}
|
||||
type="button"
|
||||
onClick={() => navigate(`/jobs/${run.job._id}`)}
|
||||
onClick={() => navigate(run.link)}
|
||||
className="flex w-full items-center justify-between rounded-md border border-border bg-muted/40 px-3 py-2 text-left transition hover:bg-muted"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<RepoIcon repoUrl={run.job.repoUrl} />
|
||||
<RepoIcon repoUrl={run.repoUrl} />
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-foreground">{run.job.name}</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-semibold ${
|
||||
run.type === "test"
|
||||
? "border-sky-200 bg-sky-100 text-sky-700"
|
||||
: "border-amber-200 bg-amber-100 text-amber-800"
|
||||
}`}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={run.type === "test" ? faFlaskVial : faRocket}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
{run.type === "test" ? "Test" : "Deploy"}
|
||||
</span>
|
||||
<span>{run.title}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{new Date(run.startedAt).toLocaleString()} · Süre: {formatDuration(run.durationMs)}
|
||||
{new Date(run.startedAt).toLocaleString()} · Süre:{" "}
|
||||
{formatDuration(run.durationMs)}
|
||||
{run.type === "deploy" && run.message ? ` · ${run.message}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
165
frontend/src/pages/SettingsPage.tsx
Normal file
165
frontend/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCopy, faEye, faEyeSlash, faRotate } from "@fortawesome/free-solid-svg-icons";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
||||
import { fetchSettings, rotateWebhookSecret, rotateWebhookToken, SettingsResponse } from "../api/settings";
|
||||
|
||||
export function SettingsPage() {
|
||||
const [settings, setSettings] = useState<SettingsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [rotatingToken, setRotatingToken] = useState(false);
|
||||
const [rotatingSecret, setRotatingSecret] = useState(false);
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings()
|
||||
.then((data) => setSettings(data))
|
||||
.catch(() => toast.error("Settings yüklenemedi"))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleCopy = async (value: string, label: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast.success(`${label} kopyalandı`);
|
||||
} catch {
|
||||
toast.error(`${label} kopyalanamadı`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRotateToken = async () => {
|
||||
const ok = window.confirm(
|
||||
"API Token yenilenecek. Gitea webhook ayarları güncellenmezse mevcut deployment'lar tetiklenmez. Devam etmek istiyor musun?"
|
||||
);
|
||||
if (!ok) return;
|
||||
setRotatingToken(true);
|
||||
try {
|
||||
const data = await rotateWebhookToken();
|
||||
setSettings((prev) => (prev ? { ...prev, webhookToken: data.webhookToken } : data));
|
||||
toast.success("API token yenilendi");
|
||||
} catch {
|
||||
toast.error("API token yenilenemedi");
|
||||
} finally {
|
||||
setRotatingToken(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRotateSecret = async () => {
|
||||
const ok = window.confirm(
|
||||
"Webhook Secret yenilenecek. Gitea webhook ayarları güncellenmezse imza doğrulaması başarısız olur. Devam etmek istiyor musun?"
|
||||
);
|
||||
if (!ok) return;
|
||||
setRotatingSecret(true);
|
||||
try {
|
||||
const data = await rotateWebhookSecret();
|
||||
setSettings((prev) => (prev ? { ...prev, webhookSecret: data.webhookSecret } : data));
|
||||
toast.success("Webhook secret yenilendi");
|
||||
} catch {
|
||||
toast.error("Webhook secret yenilenemedi");
|
||||
} finally {
|
||||
setRotatingSecret(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
|
||||
Settings yükleniyor...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!settings) {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
|
||||
Settings bulunamadı.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground">Settings</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Gitea webhook çağrıları için API token ve secret bilgileri.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>API Token</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleRotateToken} disabled={rotatingToken}>
|
||||
<FontAwesomeIcon icon={faRotate} className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between gap-3 rounded-md bg-muted px-3 py-2 text-sm">
|
||||
<code className="break-all text-foreground/80">
|
||||
{showToken ? settings.webhookToken : "•".repeat(settings.webhookToken.length)}
|
||||
</code>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowToken((prev) => !prev)}
|
||||
title={showToken ? "Gizle" : "Göster"}
|
||||
>
|
||||
<FontAwesomeIcon icon={showToken ? faEyeSlash : faEye} className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleCopy(settings.webhookToken, "API token")}
|
||||
title="Kopyala"
|
||||
>
|
||||
<FontAwesomeIcon icon={faCopy} className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Webhook Secret</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleRotateSecret} disabled={rotatingSecret}>
|
||||
<FontAwesomeIcon icon={faRotate} className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between gap-3 rounded-md bg-muted px-3 py-2 text-sm">
|
||||
<code className="break-all text-foreground/80">
|
||||
{showSecret ? settings.webhookSecret : "•".repeat(settings.webhookSecret.length)}
|
||||
</code>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowSecret((prev) => !prev)}
|
||||
title={showSecret ? "Gizle" : "Göster"}
|
||||
>
|
||||
<FontAwesomeIcon icon={showSecret ? faEyeSlash : faEye} className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleCopy(settings.webhookSecret, "Webhook secret")}
|
||||
title="Kopyala"
|
||||
>
|
||||
<FontAwesomeIcon icon={faCopy} className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user