feat(deployments): anlık durum ve log izleme özelliği ekle

- Socket.IO tabanlı gerçek zamanlı deployment log ve durum bildirimleri ekle
- deployment:subscribe ve deployment:unsubscribe soket olaylarını destekle
- DeploymentService'e anlık durum ve log yayınlama özelliği ekle
- Deployment silinirken docker kaynaklarını temizle
- Ortam değişkenlerini tek bir .env.example dosyasında birleştir
- Docker compose yapılandırmasını güncelle (PWD ve DEPLOYMENTS_ROOT kullan)
- Repo URL'sinden proje adını otomatik öner
- Güvensiz bağlamlar için clipboard kopya fallback mekanizması ekle
- Socket.IO path'ini /api/socket.io olarak ayarla
This commit is contained in:
2026-01-19 15:11:45 +03:00
parent a87baa653a
commit e7a5690d98
14 changed files with 257 additions and 35 deletions

View File

@@ -1,3 +1,14 @@
# Backend Environment
PORT=4000
MONGO_URI=mongodb://mongo:27017/wisecoltci
ADMIN_USERNAME=admin
ADMIN_PASSWORD=supersecret
JWT_SECRET=change-me
CLIENT_ORIGIN=http://localhost:5173
# Frontend Environment
VITE_API_URL=http://localhost:4000/api
# ---------------------------------- CLAUDE API SETTINGS ---------------------------------- # # ---------------------------------- CLAUDE API SETTINGS ---------------------------------- #
# === Claude API Config === # === Claude API Config ===
API_KEY_LITE="your-lite-key" API_KEY_LITE="your-lite-key"
@@ -7,6 +18,3 @@ ACTIVE_KEY=lite
# === Anthropic API Settings === # === Anthropic API Settings ===
ANTHROPIC_BASE_URL="https://api.z.ai/api/anthropic" ANTHROPIC_BASE_URL="https://api.z.ai/api/anthropic"
ANTHROPIC_MODEL="glm-4.7" ANTHROPIC_MODEL="glm-4.7"
# Host üzerinde projelerin bulunduğu dizin (compose volume için, zorunludur)
DEPLOYMENTS_ROOT_HOST=/home/wisecolt-dev/workspace

View File

@@ -1,9 +0,0 @@
PORT=4000
# Prod için zorunlu Mongo bağlantısı
# Örnek: mongodb://mongo:27017/wisecoltci
MONGO_URI=mongodb://mongo:27017/wisecoltci
ADMIN_USERNAME=admin
ADMIN_PASSWORD=supersecret
JWT_SECRET=change-me
CLIENT_ORIGIN=http://localhost:5173
DEPLOYMENTS_ROOT_HOST=/home/wisecolt-dev/workspace

View File

@@ -1,4 +1,5 @@
import dotenv from "dotenv"; import dotenv from "dotenv";
import path from "path";
dotenv.config(); dotenv.config();
@@ -8,7 +9,8 @@ export const config = {
adminUsername: process.env.ADMIN_USERNAME || "admin", adminUsername: process.env.ADMIN_USERNAME || "admin",
adminPassword: process.env.ADMIN_PASSWORD || "password", adminPassword: process.env.ADMIN_PASSWORD || "password",
jwtSecret: process.env.JWT_SECRET || "changeme", jwtSecret: process.env.JWT_SECRET || "changeme",
clientOrigin: process.env.CLIENT_ORIGIN || "http://localhost:5173" clientOrigin: process.env.CLIENT_ORIGIN || "http://localhost:5173",
deploymentsRoot: process.env.DEPLOYMENTS_ROOT || path.join(process.cwd(), "deployments")
}; };
if (!config.jwtSecret) { if (!config.jwtSecret) {

View File

@@ -11,6 +11,8 @@ import webhookRoutes from "./routes/webhooks.js";
import { config } from "./config/env.js"; import { config } from "./config/env.js";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import { jobService } from "./services/jobService.js"; import { jobService } from "./services/jobService.js";
import { deploymentService } from "./services/deploymentService.js";
import { DeploymentProject } from "./models/deploymentProject.js";
import { Job } from "./models/job.js"; import { Job } from "./models/job.js";
const app = express(); const app = express();
@@ -42,6 +44,7 @@ app.use("/api/settings", settingsRoutes);
const server = http.createServer(app); const server = http.createServer(app);
const io = new Server(server, { const io = new Server(server, {
path: "/api/socket.io",
cors: { cors: {
origin: config.clientOrigin, origin: config.clientOrigin,
methods: ["GET", "POST"] methods: ["GET", "POST"]
@@ -49,6 +52,7 @@ const io = new Server(server, {
}); });
jobService.setSocket(io); jobService.setSocket(io);
deploymentService.setSocket(io);
io.use((socket, next) => { io.use((socket, next) => {
const token = socket.handshake.auth?.token as string | undefined; const token = socket.handshake.auth?.token as string | undefined;
@@ -93,6 +97,29 @@ io.on("connection", (socket) => {
if (!jobId) return; if (!jobId) return;
socket.leave(`job:${jobId}`); socket.leave(`job:${jobId}`);
}); });
socket.on("deployment:subscribe", async ({ deploymentId }: { deploymentId: string }) => {
if (!deploymentId) return;
socket.join(`deployment:${deploymentId}`);
try {
const deployment = await DeploymentProject.findById(deploymentId);
if (deployment) {
socket.emit("deployment:status", {
deploymentId,
status: deployment.lastStatus,
lastRunAt: deployment.lastDeployAt,
lastMessage: deployment.lastMessage
});
}
} catch {
// sessizce geç
}
});
socket.on("deployment:unsubscribe", ({ deploymentId }: { deploymentId: string }) => {
if (!deploymentId) return;
socket.leave(`deployment:${deploymentId}`);
});
}); });
async function start() { async function start() {

View File

@@ -5,6 +5,7 @@ import { authMiddleware } from "../middleware/authMiddleware.js";
import { deploymentService } from "../services/deploymentService.js"; import { deploymentService } from "../services/deploymentService.js";
import { DeploymentProject } from "../models/deploymentProject.js"; import { DeploymentProject } from "../models/deploymentProject.js";
import { DeploymentRun } from "../models/deploymentRun.js"; import { DeploymentRun } from "../models/deploymentRun.js";
import fs from "fs";
const router = Router(); const router = Router();
@@ -140,6 +141,9 @@ router.post("/", async (req, res) => {
composeFile, composeFile,
port port
}); });
deploymentService
.runDeployment(created._id.toString(), { message: "First deployment" })
.catch(() => undefined);
return res.status(201).json(created); return res.status(201).json(created);
} catch (err) { } catch (err) {
return res.status(400).json({ message: "Deployment oluşturulamadı", error: (err as Error).message }); return res.status(400).json({ message: "Deployment oluşturulamadı", error: (err as Error).message });
@@ -174,9 +178,12 @@ router.delete("/:id", async (req, res) => {
authMiddleware(req, res, async () => { authMiddleware(req, res, async () => {
const { id } = req.params; const { id } = req.params;
try { try {
const deleted = await DeploymentProject.findByIdAndDelete(id); const project = await DeploymentProject.findById(id);
if (!deleted) return res.status(404).json({ message: "Deployment bulunamadı" }); if (!project) return res.status(404).json({ message: "Deployment bulunamadı" });
await deploymentService.cleanupProjectResources(project);
await DeploymentProject.findByIdAndDelete(id);
await DeploymentRun.deleteMany({ project: id }); await DeploymentRun.deleteMany({ project: id });
await fs.promises.rm(project.rootPath, { recursive: true, force: true });
return res.json({ success: true }); return res.json({ success: true });
} catch (err) { } catch (err) {
return res.status(400).json({ message: "Deployment silinemedi", error: (err as Error).message }); return res.status(400).json({ message: "Deployment silinemedi", error: (err as Error).message });
@@ -189,7 +196,9 @@ router.post("/:id/run", async (req, res) => {
const { id } = req.params; const { id } = req.params;
const project = await DeploymentProject.findById(id); const project = await DeploymentProject.findById(id);
if (!project) return res.status(404).json({ message: "Deployment bulunamadı" }); if (!project) return res.status(404).json({ message: "Deployment bulunamadı" });
deploymentService.runDeployment(id).catch(() => undefined); deploymentService
.runDeployment(id, { message: "Elle deploy tetikleme" })
.catch(() => undefined);
return res.json({ queued: true }); return res.json({ queued: true });
}); });
}); });

View File

@@ -2,6 +2,8 @@ import fs from "fs";
import path from "path"; import path from "path";
import crypto from "crypto"; import crypto from "crypto";
import { spawn } from "child_process"; import { spawn } from "child_process";
import { Server } from "socket.io";
import { config } from "../config/env.js";
import { import {
DeploymentProject, DeploymentProject,
DeploymentProjectDocument, DeploymentProjectDocument,
@@ -13,7 +15,7 @@ import { Settings } from "../models/settings.js";
const composeFileCandidates: ComposeFile[] = ["docker-compose.yml", "docker-compose.dev.yml"]; const composeFileCandidates: ComposeFile[] = ["docker-compose.yml", "docker-compose.dev.yml"];
const deploymentsRoot = "/workspace/deployments"; const deploymentsRoot = config.deploymentsRoot;
function slugify(value: string) { function slugify(value: string) {
return value return value
@@ -152,6 +154,39 @@ async function runCompose(project: DeploymentProjectDocument, onData: (line: str
class DeploymentService { class DeploymentService {
private running: Map<string, boolean> = new Map(); private running: Map<string, boolean> = new Map();
private io: Server | null = null;
setSocket(io: Server) {
this.io = io;
}
private async emitStatus(deploymentId: string, payload: Partial<DeploymentProjectDocument>) {
if (!this.io) return;
const runCount = await DeploymentRun.countDocuments({ project: deploymentId });
const body = {
deploymentId,
status: payload.lastStatus,
lastRunAt: payload.lastDeployAt,
lastMessage: payload.lastMessage,
runCount
};
this.io.to(`deployment:${deploymentId}`).emit("deployment:status", body);
this.io.emit("deployment:status", body);
}
private emitLog(deploymentId: string, line: string) {
if (!this.io) return;
this.io.to(`deployment:${deploymentId}`).emit("deployment:log", { deploymentId, line });
this.io.except(`deployment:${deploymentId}`).emit("deployment:log", { deploymentId, line });
}
private emitRun(deploymentId: string, run: DeploymentRun) {
if (!this.io) return;
this.io.to(`deployment:${deploymentId}`).emit("deployment:run", {
deploymentId,
run
});
}
async listRemoteBranches(repoUrl: string) { async listRemoteBranches(repoUrl: string) {
const output = await runCommandCapture("git", ["ls-remote", "--heads", repoUrl], process.cwd()); const output = await runCommandCapture("git", ["ls-remote", "--heads", repoUrl], process.cwd());
@@ -305,6 +340,7 @@ class DeploymentService {
const runLogs: string[] = []; const runLogs: string[] = [];
const pushLog = (line: string) => { const pushLog = (line: string) => {
runLogs.push(line); runLogs.push(line);
this.emitLog(projectId, line);
}; };
const runDoc = await DeploymentRun.create({ const runDoc = await DeploymentRun.create({
@@ -313,11 +349,16 @@ class DeploymentService {
startedAt: new Date(), startedAt: new Date(),
message: options?.message message: options?.message
}); });
this.emitRun(projectId, runDoc);
await DeploymentProject.findByIdAndUpdate(projectId, { await DeploymentProject.findByIdAndUpdate(projectId, {
lastStatus: "running", lastStatus: "running",
lastMessage: options?.message || "Deploy başlıyor..." lastMessage: options?.message || "Deploy başlıyor..."
}); });
await this.emitStatus(projectId, {
lastStatus: "running",
lastMessage: options?.message || "Deploy başlıyor..."
} as DeploymentProjectDocument);
try { try {
await ensureRepo(project, (line) => pushLog(line)); await ensureRepo(project, (line) => pushLog(line));
@@ -329,6 +370,11 @@ class DeploymentService {
lastDeployAt: new Date(), lastDeployAt: new Date(),
lastMessage: options?.message || "Başarılı" lastMessage: options?.message || "Başarılı"
}); });
await this.emitStatus(projectId, {
lastStatus: "success",
lastDeployAt: new Date(),
lastMessage: options?.message || "Başarılı"
} as DeploymentProjectDocument);
await DeploymentRun.findByIdAndUpdate(runDoc._id, { await DeploymentRun.findByIdAndUpdate(runDoc._id, {
status: "success", status: "success",
finishedAt: new Date(), finishedAt: new Date(),
@@ -336,6 +382,8 @@ class DeploymentService {
logs: runLogs, logs: runLogs,
message: options?.message message: options?.message
}); });
const updatedRun = await DeploymentRun.findById(runDoc._id);
if (updatedRun) this.emitRun(projectId, updatedRun);
pushLog("Deploy tamamlandı: Başarılı"); pushLog("Deploy tamamlandı: Başarılı");
} catch (err) { } catch (err) {
const duration = Date.now() - startedAt; const duration = Date.now() - startedAt;
@@ -344,6 +392,11 @@ class DeploymentService {
lastDeployAt: new Date(), lastDeployAt: new Date(),
lastMessage: (err as Error).message lastMessage: (err as Error).message
}); });
await this.emitStatus(projectId, {
lastStatus: "failed",
lastDeployAt: new Date(),
lastMessage: (err as Error).message
} as DeploymentProjectDocument);
await DeploymentRun.findByIdAndUpdate(runDoc._id, { await DeploymentRun.findByIdAndUpdate(runDoc._id, {
status: "failed", status: "failed",
finishedAt: new Date(), finishedAt: new Date(),
@@ -351,12 +404,26 @@ class DeploymentService {
logs: runLogs, logs: runLogs,
message: options?.message message: options?.message
}); });
const updatedRun = await DeploymentRun.findById(runDoc._id);
if (updatedRun) this.emitRun(projectId, updatedRun);
pushLog(`Hata: ${(err as Error).message}`); pushLog(`Hata: ${(err as Error).message}`);
} finally { } finally {
this.running.delete(projectId); this.running.delete(projectId);
} }
} }
async cleanupProjectResources(project: DeploymentProjectDocument) {
const composePath = path.join(project.rootPath, project.composeFile);
if (!fs.existsSync(composePath)) {
return;
}
await runCommand(
`docker compose -f ${project.composeFile} down --remove-orphans -v --rmi local`,
project.rootPath,
() => undefined
);
}
async findByWebhookToken(token: string) { async findByWebhookToken(token: string) {
return DeploymentProject.findOne({ webhookToken: token }); return DeploymentProject.findOne({ webhookToken: token });
} }

View File

@@ -13,10 +13,12 @@ services:
volumes: volumes:
- ./backend:/app - ./backend:/app
- /app/node_modules - /app/node_modules
- ${DEPLOYMENTS_ROOT_HOST}:/workspace - ${PWD}:${PWD}
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
env_file: env_file:
- ./backend/.env - ./.env
environment:
DEPLOYMENTS_ROOT: ${PWD}/deployments
ports: ports:
- "4000:4000" - "4000:4000"
depends_on: depends_on:
@@ -29,7 +31,7 @@ services:
- ./frontend:/app - ./frontend:/app
- /app/node_modules - /app/node_modules
env_file: env_file:
- ./frontend/.env - ./.env
ports: ports:
- "5173:5173" - "5173:5173"
depends_on: depends_on:

View File

@@ -3,10 +3,12 @@ services:
build: ./backend build: ./backend
command: npm run build && npm start command: npm run build && npm start
volumes: volumes:
- ${DEPLOYMENTS_ROOT_HOST}:/workspace - ${PWD}:${PWD}
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
env_file: env_file:
- ./backend/.env - ./.env
environment:
DEPLOYMENTS_ROOT: ${PWD}/deployments
ports: ports:
- "4000:4000" - "4000:4000"
@@ -17,7 +19,7 @@ services:
- ./frontend:/app - ./frontend:/app
- /app/node_modules - /app/node_modules
env_file: env_file:
- ./frontend/.env - ./.env
environment: environment:
ALLOWED_HOSTS: ${ALLOWED_HOSTS} ALLOWED_HOSTS: ${ALLOWED_HOSTS}
ports: ports:

View File

@@ -1,3 +0,0 @@
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

View File

@@ -7,6 +7,8 @@ import { Button } from "../components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { JobStatusBadge } from "../components/JobStatusBadge"; import { JobStatusBadge } from "../components/JobStatusBadge";
import { DeploymentProject, DeploymentRun, fetchDeployment, runDeployment } from "../api/deployments"; import { DeploymentProject, DeploymentRun, fetchDeployment, runDeployment } from "../api/deployments";
import { useDeploymentStream } from "../providers/live-provider";
import { useSocket } from "../providers/socket-provider";
export function DeploymentDetailPage() { export function DeploymentDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -15,6 +17,8 @@ export function DeploymentDetailPage() {
const [runs, setRuns] = useState<DeploymentRun[]>([]); const [runs, setRuns] = useState<DeploymentRun[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [triggering, setTriggering] = useState(false); const [triggering, setTriggering] = useState(false);
const stream = useDeploymentStream(id || "");
const socket = useSocket();
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
@@ -27,12 +31,36 @@ export function DeploymentDetailPage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [id]); }, [id]);
useEffect(() => {
if (!socket || !id) return;
socket.emit("deployment:subscribe", { deploymentId: id });
const handleRunUpdate = ({ deploymentId, run }: { deploymentId: string; run: DeploymentRun }) => {
if (deploymentId !== id) return;
setRuns((prev) => {
const existingIndex = prev.findIndex((item) => item._id === run._id);
if (existingIndex >= 0) {
const next = [...prev];
next[existingIndex] = { ...next[existingIndex], ...run };
return next;
}
return [run, ...prev];
});
};
socket.on("deployment:run", handleRunUpdate);
return () => {
socket.emit("deployment:unsubscribe", { deploymentId: id });
socket.off("deployment:run", handleRunUpdate);
};
}, [socket, id]);
const webhookUrl = useMemo(() => { const webhookUrl = useMemo(() => {
if (!project) return ""; if (!project) return "";
return `${window.location.origin}/api/deployments/webhook/${project.webhookToken}`; return `${window.location.origin}/api/deployments/webhook/${project.webhookToken}`;
}, [project]); }, [project]);
const latestRun = runs[0]; const latestRun = runs[0];
const effectiveStatus = stream.status || project?.lastStatus || latestRun?.status || "idle";
const currentLogs = stream.logs.length > 0 ? stream.logs : latestRun?.logs || [];
const decorateLogLine = (line: string) => { const decorateLogLine = (line: string) => {
const lower = line.toLowerCase(); const lower = line.toLowerCase();
@@ -56,7 +84,20 @@ export function DeploymentDetailPage() {
const handleCopy = async () => { const handleCopy = async () => {
try { try {
await navigator.clipboard.writeText(webhookUrl); if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(webhookUrl);
} else {
const textarea = document.createElement("textarea");
textarea.value = webhookUrl;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const ok = document.execCommand("copy");
document.body.removeChild(textarea);
if (!ok) throw new Error("copy failed");
}
toast.success("Webhook URL kopyalandı"); toast.success("Webhook URL kopyalandı");
} catch { } catch {
toast.error("Webhook URL kopyalanamadı"); toast.error("Webhook URL kopyalanamadı");
@@ -121,7 +162,7 @@ export function DeploymentDetailPage() {
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Genel Bilgiler</CardTitle> <CardTitle>Genel Bilgiler</CardTitle>
<JobStatusBadge status={project.lastStatus} /> <JobStatusBadge status={effectiveStatus} />
</CardHeader> </CardHeader>
<CardContent className="grid gap-4 text-sm text-muted-foreground"> <CardContent className="grid gap-4 text-sm text-muted-foreground">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
@@ -204,8 +245,8 @@ export function DeploymentDetailPage() {
</CardHeader> </CardHeader>
<CardContent> <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"> <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 ? ( {currentLogs.length ? (
latestRun.logs.map((line, idx) => ( currentLogs.map((line, idx) => (
<div key={idx} className="whitespace-pre-wrap"> <div key={idx} className="whitespace-pre-wrap">
{decorateLogLine(line)} {decorateLogLine(line)}
</div> </div>

View File

@@ -25,6 +25,7 @@ import {
updateDeployment updateDeployment
} from "../api/deployments"; } from "../api/deployments";
import { JobStatusBadge } from "../components/JobStatusBadge"; import { JobStatusBadge } from "../components/JobStatusBadge";
import { useLiveData } from "../providers/live-provider";
type FormState = { type FormState = {
_id?: string; _id?: string;
@@ -46,6 +47,7 @@ const defaultForm: FormState = {
export function DeploymentsPage() { export function DeploymentsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { deploymentStreams } = useLiveData();
const apiBase = (import.meta.env.VITE_API_URL || "").replace(/\/$/, ""); const apiBase = (import.meta.env.VITE_API_URL || "").replace(/\/$/, "");
const [deployments, setDeployments] = useState<DeploymentProject[]>([]); const [deployments, setDeployments] = useState<DeploymentProject[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -84,6 +86,14 @@ export function DeploymentsPage() {
setComposeOptions([]); setComposeOptions([]);
return; return;
} }
if (!form._id && !form.name) {
const normalized = repoUrl.replace(/\/+$/, "");
const lastPart = normalized.split("/").pop() || "";
const name = lastPart.replace(/\.git$/i, "");
if (name) {
setForm((prev) => ({ ...prev, name }));
}
}
const timer = setTimeout(async () => { const timer = setTimeout(async () => {
setBranchLoading(true); setBranchLoading(true);
try { try {
@@ -292,7 +302,9 @@ export function DeploymentsPage() {
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground"> <div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<JobStatusBadge status={deployment.lastStatus} /> <JobStatusBadge
status={deploymentStreams[deployment._id]?.status || deployment.lastStatus}
/>
<span className="rounded-md bg-muted px-2 py-1 text-xs font-semibold text-foreground/80"> <span className="rounded-md bg-muted px-2 py-1 text-xs font-semibold text-foreground/80">
{deployment.env.toUpperCase()} {deployment.env.toUpperCase()}
</span> </span>

View File

@@ -23,7 +23,20 @@ export function SettingsPage() {
const handleCopy = async (value: string, label: string) => { const handleCopy = async (value: string, label: string) => {
try { try {
await navigator.clipboard.writeText(value); if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(value);
} else {
const textarea = document.createElement("textarea");
textarea.value = value;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const ok = document.execCommand("copy");
document.body.removeChild(textarea);
if (!ok) throw new Error("copy failed");
}
toast.success(`${label} kopyalandı`); toast.success(`${label} kopyalandı`);
} catch { } catch {
toast.error(`${label} kopyalanamadı`); toast.error(`${label} kopyalanamadı`);

View File

@@ -12,6 +12,7 @@ type JobStream = {
type LiveContextValue = { type LiveContextValue = {
jobStreams: Record<string, JobStream>; jobStreams: Record<string, JobStream>;
deploymentStreams: Record<string, JobStream>;
}; };
const LiveContext = createContext<LiveContextValue | undefined>(undefined); const LiveContext = createContext<LiveContextValue | undefined>(undefined);
@@ -19,6 +20,7 @@ const LiveContext = createContext<LiveContextValue | undefined>(undefined);
export const LiveProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const LiveProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const socket = useSocket(); const socket = useSocket();
const [jobStreams, setJobStreams] = useState<Record<string, JobStream>>({}); const [jobStreams, setJobStreams] = useState<Record<string, JobStream>>({});
const [deploymentStreams, setDeploymentStreams] = useState<Record<string, JobStream>>({});
useEffect(() => { useEffect(() => {
if (!socket) return; if (!socket) return;
@@ -54,20 +56,59 @@ export const LiveProvider: React.FC<{ children: React.ReactNode }> = ({ children
}); });
}; };
const handleDeploymentLog = ({ deploymentId, line }: { deploymentId: string; line: string }) => {
if (!deploymentId) return;
setDeploymentStreams((prev) => {
const current = prev[deploymentId] || { logs: [] };
const nextLogs = [...current.logs, line].slice(-200);
return { ...prev, [deploymentId]: { ...current, logs: nextLogs } };
});
};
const handleDeploymentStatus = ({
deploymentId,
status,
lastRunAt,
lastMessage,
runCount,
lastDurationMs
}: {
deploymentId: string;
status?: string;
lastRunAt?: string;
lastMessage?: string;
runCount?: number;
lastDurationMs?: number;
}) => {
if (!deploymentId) return;
setDeploymentStreams((prev) => {
const current = prev[deploymentId] || { logs: [] };
return {
...prev,
[deploymentId]: { ...current, status, lastRunAt, lastMessage, runCount, lastDurationMs }
};
});
};
socket.on("job:log", handleJobLog); socket.on("job:log", handleJobLog);
socket.on("job:status", handleJobStatus); socket.on("job:status", handleJobStatus);
socket.on("deployment:log", handleDeploymentLog);
socket.on("deployment:status", handleDeploymentStatus);
return () => { return () => {
socket.off("job:log", handleJobLog); socket.off("job:log", handleJobLog);
socket.off("job:status", handleJobStatus); socket.off("job:status", handleJobStatus);
socket.off("deployment:log", handleDeploymentLog);
socket.off("deployment:status", handleDeploymentStatus);
}; };
}, [socket]); }, [socket]);
const value = useMemo( const value = useMemo(
() => ({ () => ({
jobStreams jobStreams,
deploymentStreams
}), }),
[jobStreams] [jobStreams, deploymentStreams]
); );
return <LiveContext.Provider value={value}>{children}</LiveContext.Provider>; return <LiveContext.Provider value={value}>{children}</LiveContext.Provider>;
@@ -87,3 +128,12 @@ export function useJobStream(jobId: string) {
[ctx.jobStreams, jobId] [ctx.jobStreams, jobId]
); );
} }
export function useDeploymentStream(deploymentId: string) {
const ctx = useContext(LiveContext);
if (!ctx) throw new Error("useDeploymentStream LiveProvider içinde kullanılmalı");
return useMemo(
() => ctx.deploymentStreams[deploymentId] || { logs: [], status: "idle", runCount: 0 },
[ctx.deploymentStreams, deploymentId]
);
}

View File

@@ -10,7 +10,7 @@ export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ childr
const socketRef = useRef<Socket | null>(null); const socketRef = useRef<Socket | null>(null);
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const baseUrl = useMemo(() => apiClient.defaults.baseURL || window.location.origin, []); const baseUrl = useMemo(() => window.location.origin, []);
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
@@ -22,6 +22,7 @@ export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ childr
const socket = io(baseUrl, { const socket = io(baseUrl, {
auth: { token }, auth: { token },
path: "/api/socket.io",
transports: ["websocket", "polling"] transports: ["websocket", "polling"]
}); });