first commit
This commit is contained in:
17
.env.example
Normal file
17
.env.example
Normal file
@@ -0,0 +1,17 @@
|
||||
QBIT_BASE_URL=http://zimaos.bee:8181
|
||||
QBIT_USERNAME=admin
|
||||
QBIT_PASSWORD=adminadmin
|
||||
APP_USERNAME=qbuffer
|
||||
APP_PASSWORD=changeme
|
||||
JWT_SECRET=replace_me
|
||||
APP_HOST=localhost
|
||||
WEB_ORIGIN=http://localhost:5173
|
||||
SERVER_PORT=3001
|
||||
WEB_PORT=5173
|
||||
POLL_INTERVAL_MS=3000
|
||||
ENFORCE_INTERVAL_MS=2000
|
||||
DEFAULT_DELAY_MS=3000
|
||||
MAX_LOOP_LIMIT=1000
|
||||
STALLED_RECOVERY_MS=300000
|
||||
TIMER_POLL_MS=60000
|
||||
NODE_ENV=development
|
||||
82
.gitignore
vendored
Normal file
82
.gitignore
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
# Node modules
|
||||
*/node_modules/
|
||||
jspm_packages/
|
||||
/data
|
||||
.kilocode/
|
||||
client/dist
|
||||
claudedocs/
|
||||
data/
|
||||
node_modules/
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Runtime data
|
||||
pids *.pid *.seed *.pid.lock
|
||||
|
||||
# Coverage directory
|
||||
.nyc_output/
|
||||
coverage/
|
||||
lcov-report/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.*.local
|
||||
!.env.example
|
||||
.pnpm-store/
|
||||
|
||||
# Build / output directories
|
||||
/dist/
|
||||
/build/
|
||||
/output/
|
||||
/. svelte-kit/
|
||||
.svelte-kit/
|
||||
.vite/
|
||||
.tmp/
|
||||
.cache/
|
||||
|
||||
# Docker files / volumes
|
||||
docker-compose.override.yml
|
||||
docker-compose.*.yml
|
||||
docker/*-volume/
|
||||
docker/*-data/
|
||||
*.tar
|
||||
*.img
|
||||
|
||||
# OS / IDE stuff
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
*.swp
|
||||
.vscode/
|
||||
.idea/
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
|
||||
# Media / Download directories (depending on your setup)
|
||||
downloads/
|
||||
cache/
|
||||
movie/movieData/
|
||||
movie/movieData/**/subtitles/
|
||||
movie/movieData/**/poster.jpg
|
||||
movie/movieData/**/backdrop.jpg
|
||||
|
||||
# Generic placeholders
|
||||
*.gitkeep
|
||||
|
||||
# Torrent / upload temp files
|
||||
/uploads/
|
||||
/uploads/*
|
||||
*.torrent
|
||||
*.part
|
||||
*.temp
|
||||
|
||||
# Other sensitive files
|
||||
/key.pem
|
||||
/cert.pem
|
||||
*.log
|
||||
|
||||
# Client
|
||||
client/src/assets/avatar_.png
|
||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
FROM node:20-alpine AS base
|
||||
WORKDIR /app
|
||||
RUN corepack enable
|
||||
|
||||
FROM base AS deps
|
||||
COPY package.json pnpm-workspace.yaml ./
|
||||
COPY apps/server/package.json apps/server/package.json
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
RUN pnpm install --frozen-lockfile=false
|
||||
|
||||
FROM deps AS build
|
||||
COPY . .
|
||||
RUN pnpm -C apps/web build
|
||||
RUN pnpm -C apps/server build
|
||||
|
||||
FROM base AS prod
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules /app/node_modules
|
||||
COPY --from=build /app/apps/server/dist /app/apps/server/dist
|
||||
COPY --from=build /app/apps/server/package.json /app/apps/server/package.json
|
||||
COPY --from=build /app/apps/server/public /app/apps/server/public
|
||||
EXPOSE 3001
|
||||
CMD ["node", "/app/apps/server/dist/index.js"]
|
||||
59
README.md
Normal file
59
README.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# q-buffer
|
||||
|
||||
A production-ready monorepo that orchestrates qBittorrent torrents in a controlled playback loop with strict peer enforcement.
|
||||
|
||||
## Overview
|
||||
|
||||
- Backend: Node.js + TypeScript + Express + socket.io
|
||||
- Frontend: React + Vite + TypeScript (shadcn-style UI)
|
||||
- Storage: JSON file DB with atomic writes and mutex
|
||||
- Docker: dev (two containers) and prod (single container)
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Copy `.env.example` to `.env` and fill values.
|
||||
2. Start dev stack:
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose.dev.yml up --build
|
||||
```
|
||||
|
||||
3. Open:
|
||||
|
||||
- Web: http://localhost:5173
|
||||
- API/Socket: http://localhost:3001
|
||||
|
||||
## Production
|
||||
|
||||
```bash
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
Open http://localhost:3001
|
||||
|
||||
## Features
|
||||
|
||||
- Login with env-configured credentials (JWT httpOnly cookie)
|
||||
- Torrent list and selection
|
||||
- Torrent archive to `/data/torrents/{hash}.torrent`
|
||||
- Loop engine with delete/re-add between runs
|
||||
- Aggressive allow-IP enforcement (peer ban when supported)
|
||||
- Dry run report and profiles
|
||||
- Real-time status/logs via socket.io
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `QBIT_BASE_URL`, `QBIT_USERNAME`, `QBIT_PASSWORD`
|
||||
- `APP_USERNAME`, `APP_PASSWORD`, `JWT_SECRET`
|
||||
- `POLL_INTERVAL_MS`, `ENFORCE_INTERVAL_MS`, `DEFAULT_DELAY_MS`, `MAX_LOOP_LIMIT`
|
||||
|
||||
## Folder Layout
|
||||
|
||||
- `apps/server`: Express API + socket.io
|
||||
- `apps/web`: Vite React UI
|
||||
- `data`: JSON DB, logs, torrent archive
|
||||
|
||||
## Notes
|
||||
|
||||
- If magnet metadata generation fails, use Advanced upload to provide `.torrent` manually.
|
||||
- The loop engine deletes downloaded data between loops.
|
||||
10
apps/server/Dockerfile
Normal file
10
apps/server/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
RUN corepack enable
|
||||
COPY package.json /app/apps/server/package.json
|
||||
COPY package.json /app/package.json
|
||||
COPY pnpm-workspace.yaml /app/pnpm-workspace.yaml
|
||||
RUN pnpm install --frozen-lockfile=false
|
||||
WORKDIR /app/apps/server
|
||||
EXPOSE 3001
|
||||
CMD ["pnpm", "dev"]
|
||||
38
apps/server/package.json
Normal file
38
apps/server/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "q-buffer-server",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.7",
|
||||
"axios-cookiejar-support": "^5.0.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^7.3.1",
|
||||
"form-data": "^4.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"parse-torrent": "^9.1.5",
|
||||
"pino": "^9.3.2",
|
||||
"socket.io": "^4.7.5",
|
||||
"tough-cookie": "^4.1.4",
|
||||
"webtorrent": "^2.4.5",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cookie-parser": "^1.4.7",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^20.14.9",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.5.3"
|
||||
}
|
||||
}
|
||||
22
apps/server/src/auth/auth.middleware.ts
Normal file
22
apps/server/src/auth/auth.middleware.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { verifyToken } from "./auth.service"
|
||||
|
||||
export const requireAuth = (req: Request, res: Response, next: NextFunction) => {
|
||||
const token = req.cookies?.["qbuffer_token"];
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
try {
|
||||
const payload = verifyToken(token);
|
||||
req.user = payload;
|
||||
return next();
|
||||
} catch (error) {
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
};
|
||||
|
||||
declare module "express-serve-static-core" {
|
||||
interface Request {
|
||||
user?: { username: string };
|
||||
}
|
||||
}
|
||||
64
apps/server/src/auth/auth.routes.ts
Normal file
64
apps/server/src/auth/auth.routes.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Router } from "express";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import { signToken, verifyCredentials, verifyToken } from "./auth.service"
|
||||
import { isDev } from "../config"
|
||||
|
||||
const router = Router();
|
||||
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 60_000,
|
||||
max: 5,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
router.post("/login", loginLimiter, async (req, res) => {
|
||||
const { username, password } = req.body ?? {};
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: "Missing credentials" });
|
||||
}
|
||||
const user = await verifyCredentials(username, password);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: "Invalid credentials" });
|
||||
}
|
||||
const token = signToken({ username: user.username });
|
||||
res.cookie("qbuffer_token", token, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: !isDev,
|
||||
});
|
||||
return res.json({ username: user.username });
|
||||
});
|
||||
|
||||
router.post("/logout", (_req, res) => {
|
||||
res.clearCookie("qbuffer_token");
|
||||
return res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get("/me", (req, res) => {
|
||||
const token = req.cookies?.["qbuffer_token"];
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
try {
|
||||
const payload = verifyToken(token);
|
||||
return res.json({ ok: true, username: payload.username });
|
||||
} catch (error) {
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/socket-token", (req, res) => {
|
||||
const token = req.cookies?.["qbuffer_token"];
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
try {
|
||||
verifyToken(token);
|
||||
return res.json({ token });
|
||||
} catch (error) {
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
48
apps/server/src/auth/auth.service.ts
Normal file
48
apps/server/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { config } from "../config"
|
||||
import { readDb, writeDb } from "../storage/jsondb"
|
||||
import { nowIso } from "../utils/time"
|
||||
import { User } from "../types"
|
||||
|
||||
const ensureSeedUser = async (): Promise<User | null> => {
|
||||
if (!config.appUsername || !config.appPassword) {
|
||||
return null;
|
||||
}
|
||||
const db = await readDb();
|
||||
const existing = db.users.find((user) => user.username === config.appUsername);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const passwordHash = await bcrypt.hash(config.appPassword, 10);
|
||||
const newUser: User = {
|
||||
username: config.appUsername,
|
||||
passwordHash,
|
||||
createdAt: nowIso(),
|
||||
};
|
||||
db.users.push(newUser);
|
||||
await writeDb(db);
|
||||
return newUser;
|
||||
};
|
||||
|
||||
export const initAuth = async () => {
|
||||
await ensureSeedUser();
|
||||
};
|
||||
|
||||
export const verifyCredentials = async (username: string, password: string) => {
|
||||
const db = await readDb();
|
||||
const user = db.users.find((u) => u.username === username);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
const match = await bcrypt.compare(password, user.passwordHash);
|
||||
return match ? user : null;
|
||||
};
|
||||
|
||||
export const signToken = (payload: { username: string }) => {
|
||||
return jwt.sign(payload, config.jwtSecret, { expiresIn: "7d" });
|
||||
};
|
||||
|
||||
export const verifyToken = (token: string) => {
|
||||
return jwt.verify(token, config.jwtSecret) as { username: string };
|
||||
};
|
||||
32
apps/server/src/config.ts
Normal file
32
apps/server/src/config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import path from "node:path";
|
||||
|
||||
const envNumber = (value: string | undefined, fallback: number) => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
};
|
||||
|
||||
export const config = {
|
||||
port: envNumber(process.env.SERVER_PORT, 3001),
|
||||
nodeEnv: process.env.NODE_ENV ?? "development",
|
||||
qbitBaseUrl: process.env.QBIT_BASE_URL ?? "",
|
||||
qbitUsername: process.env.QBIT_USERNAME ?? "",
|
||||
qbitPassword: process.env.QBIT_PASSWORD ?? "",
|
||||
appUsername: process.env.APP_USERNAME ?? "",
|
||||
appPassword: process.env.APP_PASSWORD ?? "",
|
||||
jwtSecret: process.env.JWT_SECRET ?? "",
|
||||
pollIntervalMs: envNumber(process.env.POLL_INTERVAL_MS, 3000),
|
||||
enforceIntervalMs: envNumber(process.env.ENFORCE_INTERVAL_MS, 2000),
|
||||
defaultDelayMs: envNumber(process.env.DEFAULT_DELAY_MS, 3000),
|
||||
maxLoopLimit: envNumber(process.env.MAX_LOOP_LIMIT, 20),
|
||||
stalledRecoveryMs: envNumber(process.env.STALLED_RECOVERY_MS, 300_000),
|
||||
timerPollMs: envNumber(process.env.TIMER_POLL_MS, 60_000),
|
||||
webPort: envNumber(process.env.WEB_PORT, 5173),
|
||||
webOrigin: process.env.WEB_ORIGIN ?? "",
|
||||
dataDir: "/app/data",
|
||||
dbPath: "/app/data/db.json",
|
||||
logsPath: "/app/data/logs.json",
|
||||
torrentArchiveDir: "/app/data/torrents",
|
||||
webPublicDir: path.resolve("/app/apps/server/public"),
|
||||
};
|
||||
|
||||
export const isDev = config.nodeEnv !== "production";
|
||||
5
apps/server/src/enforcement/enforcement.types.ts
Normal file
5
apps/server/src/enforcement/enforcement.types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface EnforcementResult {
|
||||
jobId: string;
|
||||
bannedIps: string[];
|
||||
allowIpConnected: boolean;
|
||||
}
|
||||
122
apps/server/src/enforcement/enforcement.worker.ts
Normal file
122
apps/server/src/enforcement/enforcement.worker.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import axios from "axios";
|
||||
import { getQbitCapabilities, getQbitClient } from "../qbit/qbit.context";
|
||||
import { readDb, writeDb } from "../storage/jsondb";
|
||||
import { nowIso } from "../utils/time";
|
||||
import { emitJobLog, emitJobMetrics } from "../realtime/emitter";
|
||||
import { appendAuditLog } from "../utils/logger";
|
||||
|
||||
const peerErrorThrottle = new Map<string, number>();
|
||||
|
||||
export const startEnforcementWorker = (intervalMs: number) => {
|
||||
setInterval(async () => {
|
||||
let db;
|
||||
try {
|
||||
const qbit = getQbitClient();
|
||||
const caps = getQbitCapabilities();
|
||||
db = await readDb();
|
||||
|
||||
for (const job of db.loopJobs) {
|
||||
try {
|
||||
if (job.status !== "RUNNING") {
|
||||
continue;
|
||||
}
|
||||
if (!caps) {
|
||||
continue;
|
||||
}
|
||||
if (!caps?.hasPeersEndpoint) {
|
||||
emitJobLog({
|
||||
jobId: job.id,
|
||||
level: "WARN",
|
||||
message: "Peer listing unsupported; enforcement disabled",
|
||||
createdAt: nowIso(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let peersResponse;
|
||||
try {
|
||||
peersResponse = await qbit.getTorrentPeers(job.torrentHash);
|
||||
} catch (error) {
|
||||
const status = axios.isAxiosError(error) ? error.response?.status : undefined;
|
||||
if (status === 404) {
|
||||
const lastWarn = peerErrorThrottle.get(job.id) ?? 0;
|
||||
if (Date.now() - lastWarn > 60_000) {
|
||||
emitJobLog({
|
||||
jobId: job.id,
|
||||
level: "WARN",
|
||||
message: "Peer listesi desteklenmiyor; enforcement devre dışı.",
|
||||
createdAt: nowIso(),
|
||||
});
|
||||
peerErrorThrottle.set(job.id, Date.now());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const peers = Object.values(peersResponse.peers || {});
|
||||
let allowIpConnected = false;
|
||||
const banned: string[] = [];
|
||||
|
||||
for (const peer of peers) {
|
||||
if (peer.ip === job.allowIp) {
|
||||
allowIpConnected = true;
|
||||
continue;
|
||||
}
|
||||
if (caps?.hasBanEndpoint) {
|
||||
const peerKey = `${peer.ip}:${peer.port}`;
|
||||
banned.push(peerKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (banned.length > 0 && caps?.hasBanEndpoint) {
|
||||
await qbit.banPeers(banned);
|
||||
job.bans.bannedIps.push(...banned.map((peer) => peer.split(":")[0]));
|
||||
job.bans.lastBanAt = nowIso();
|
||||
emitJobLog({
|
||||
jobId: job.id,
|
||||
level: "WARN",
|
||||
message: `Banned ${banned.length} peers`,
|
||||
createdAt: nowIso(),
|
||||
});
|
||||
await appendAuditLog({
|
||||
level: "WARN",
|
||||
event: "PEER_BANNED",
|
||||
message: `Job ${job.id}: banned ${banned.length} peers`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!caps?.hasBanEndpoint) {
|
||||
emitJobLog({
|
||||
jobId: job.id,
|
||||
level: "WARN",
|
||||
message: "Peer ban unsupported; warn-only enforcement",
|
||||
createdAt: nowIso(),
|
||||
});
|
||||
}
|
||||
|
||||
if (!allowIpConnected) {
|
||||
emitJobLog({
|
||||
jobId: job.id,
|
||||
level: "WARN",
|
||||
message: "Allowed IP not connected",
|
||||
createdAt: nowIso(),
|
||||
});
|
||||
}
|
||||
|
||||
job.updatedAt = nowIso();
|
||||
emitJobMetrics(job);
|
||||
} catch (error) {
|
||||
emitJobLog({
|
||||
jobId: job.id,
|
||||
level: "ERROR",
|
||||
message: "Enforcement error; continuing.",
|
||||
createdAt: nowIso(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await writeDb(db);
|
||||
} catch (error) {
|
||||
// Keep worker alive on errors.
|
||||
}
|
||||
}, intervalMs);
|
||||
};
|
||||
112
apps/server/src/index.ts
Normal file
112
apps/server/src/index.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import express from "express";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import cookieParser from "cookie-parser";
|
||||
import cors from "cors";
|
||||
import { config, isDev } from "./config"
|
||||
import { ensureDataPaths } from "./storage/paths"
|
||||
import { initAuth } from "./auth/auth.service"
|
||||
import authRoutes from "./auth/auth.routes"
|
||||
import { requireAuth } from "./auth/auth.middleware"
|
||||
import qbitRoutes from "./qbit/qbit.routes"
|
||||
import torrentRoutes from "./torrent/torrent.routes"
|
||||
import loopRoutes from "./loop/loop.routes"
|
||||
import profilesRoutes from "./loop/profiles.routes"
|
||||
import statusRoutes from "./status/status.routes"
|
||||
import timerRoutes from "./timer/timer.routes"
|
||||
import { QbitClient } from "./qbit/qbit.client"
|
||||
import { detectCapabilities } from "./qbit/qbit.capabilities"
|
||||
import { setQbitClient, setQbitCapabilities } from "./qbit/qbit.context"
|
||||
import { setQbitStatus } from "./status/status.service"
|
||||
import { createSocketServer } from "./realtime/socket"
|
||||
import { initEmitter, emitQbitHealth } from "./realtime/emitter"
|
||||
import { startLoopScheduler } from "./loop/loop.scheduler"
|
||||
import { startEnforcementWorker } from "./enforcement/enforcement.worker"
|
||||
import { startTimerWorker } from "./timer/timer.worker"
|
||||
import { logger } from "./utils/logger"
|
||||
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
logger.error({ reason }, "Unhandled promise rejection");
|
||||
});
|
||||
|
||||
process.on("uncaughtException", (error) => {
|
||||
logger.error({ error }, "Uncaught exception");
|
||||
});
|
||||
|
||||
let serverStarted = false;
|
||||
|
||||
const bootstrap = async () => {
|
||||
await ensureDataPaths();
|
||||
await initAuth();
|
||||
|
||||
const app = express();
|
||||
app.use(cookieParser());
|
||||
app.use(express.json());
|
||||
|
||||
if (isDev) {
|
||||
const fallbackOrigin = `http://localhost:${config.webPort}`;
|
||||
const origins = [config.webOrigin || fallbackOrigin, fallbackOrigin];
|
||||
app.use(
|
||||
cors({
|
||||
origin: origins,
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
app.use("/api/auth", authRoutes);
|
||||
app.use("/api/qbit", requireAuth, qbitRoutes);
|
||||
app.use("/api/torrent", requireAuth, torrentRoutes);
|
||||
app.use("/api/loop", requireAuth, loopRoutes);
|
||||
app.use("/api/profiles", requireAuth, profilesRoutes);
|
||||
app.use("/api/status", requireAuth, statusRoutes);
|
||||
app.use("/api/timer", requireAuth, timerRoutes);
|
||||
|
||||
if (!isDev) {
|
||||
app.use(express.static(config.webPublicDir));
|
||||
app.get("*", (req, res, next) => {
|
||||
if (req.path.startsWith("/api")) {
|
||||
return next();
|
||||
}
|
||||
return res.sendFile(path.join(config.webPublicDir, "index.html"));
|
||||
});
|
||||
}
|
||||
|
||||
const server = http.createServer(app);
|
||||
const io = createSocketServer(server);
|
||||
initEmitter(io);
|
||||
|
||||
const qbit = new QbitClient();
|
||||
setQbitClient(qbit);
|
||||
|
||||
try {
|
||||
const caps = await detectCapabilities(qbit);
|
||||
setQbitCapabilities(caps);
|
||||
setQbitStatus({ ok: true, version: caps.version, capabilities: caps });
|
||||
emitQbitHealth({ ok: true, version: caps.version, capabilities: caps });
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to connect to qBittorrent");
|
||||
setQbitStatus({ ok: false, lastError: (error as Error).message });
|
||||
emitQbitHealth({ ok: false, lastError: (error as Error).message });
|
||||
}
|
||||
|
||||
startLoopScheduler(qbit, config.pollIntervalMs);
|
||||
startEnforcementWorker(config.enforceIntervalMs);
|
||||
startTimerWorker(qbit, config.timerPollMs);
|
||||
|
||||
server.listen(config.port, () => {
|
||||
serverStarted = true;
|
||||
logger.info(`q-buffer server listening on ${config.port}`);
|
||||
});
|
||||
};
|
||||
|
||||
const startWithRetry = () => {
|
||||
bootstrap().catch((error) => {
|
||||
logger.error({ error }, "Failed to start server");
|
||||
if (!serverStarted) {
|
||||
setTimeout(startWithRetry, 5000);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
startWithRetry();
|
||||
219
apps/server/src/loop/loop.engine.ts
Normal file
219
apps/server/src/loop/loop.engine.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { QbitClient } from "../qbit/qbit.client";
|
||||
import { readDb, writeDb } from "../storage/jsondb";
|
||||
import { LoopJob } from "../types";
|
||||
import { nowIso } from "../utils/time";
|
||||
import { appendAuditLog, logger } from "../utils/logger";
|
||||
import { emitJobLog, emitJobMetrics } from "../realtime/emitter";
|
||||
import { config } from "../config";
|
||||
|
||||
const logJob = async (jobId: string, level: "INFO" | "WARN" | "ERROR", message: string, event?: string) => {
|
||||
const createdAt = nowIso();
|
||||
emitJobLog({ jobId, level, message, createdAt });
|
||||
if (event) {
|
||||
await appendAuditLog({ level, event: event as any, message });
|
||||
}
|
||||
};
|
||||
|
||||
export const createLoopJob = async (
|
||||
input: {
|
||||
torrentHash: string;
|
||||
name: string;
|
||||
sizeBytes: number;
|
||||
magnet?: string;
|
||||
torrentFilePath?: string;
|
||||
allowIp: string;
|
||||
targetLoops: number;
|
||||
delayMs: number;
|
||||
}
|
||||
): Promise<LoopJob> => {
|
||||
const now = nowIso();
|
||||
const job: LoopJob = {
|
||||
id: randomUUID(),
|
||||
torrentHash: input.torrentHash,
|
||||
name: input.name,
|
||||
sizeBytes: input.sizeBytes,
|
||||
magnet: input.magnet,
|
||||
torrentFilePath: input.torrentFilePath,
|
||||
allowIp: input.allowIp,
|
||||
targetLoops: input.targetLoops,
|
||||
doneLoops: 0,
|
||||
delayMs: input.delayMs,
|
||||
deleteDataBetweenLoops: true,
|
||||
enforcementMode: "aggressive-soft",
|
||||
status: "RUNNING",
|
||||
currentRun: {
|
||||
startedAt: now,
|
||||
lastProgress: 0,
|
||||
lastProgressAt: now,
|
||||
downloadedThisRunBytes: 0,
|
||||
avgSpeed: 0,
|
||||
},
|
||||
totals: {
|
||||
totalDownloadedBytes: 0,
|
||||
totalTimeMs: 0,
|
||||
},
|
||||
bans: {
|
||||
bannedIps: [],
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const db = await readDb();
|
||||
db.loopJobs.push(job);
|
||||
await writeDb(db);
|
||||
await logJob(job.id, "INFO", `Loop job started for ${job.name}`, "JOB_STARTED");
|
||||
return job;
|
||||
};
|
||||
|
||||
export const stopLoopJob = async (jobId: string) => {
|
||||
const db = await readDb();
|
||||
const job = db.loopJobs.find((j) => j.id === jobId);
|
||||
if (!job) {
|
||||
return null;
|
||||
}
|
||||
job.status = "STOPPED";
|
||||
job.nextRunAt = undefined;
|
||||
job.currentRun = undefined;
|
||||
job.updatedAt = nowIso();
|
||||
await writeDb(db);
|
||||
await logJob(job.id, "WARN", "Loop job stopped by user");
|
||||
return job;
|
||||
};
|
||||
|
||||
export const updateJob = async (job: LoopJob) => {
|
||||
const db = await readDb();
|
||||
const index = db.loopJobs.findIndex((j) => j.id === job.id);
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
db.loopJobs[index] = job;
|
||||
await writeDb(db);
|
||||
emitJobMetrics(job);
|
||||
return job;
|
||||
};
|
||||
|
||||
export const tickLoopJobs = async (
|
||||
qbit: QbitClient,
|
||||
torrents: { hash: string; progress: number; state: string; dlspeed: number }[]
|
||||
) => {
|
||||
const db = await readDb();
|
||||
let changed = false;
|
||||
|
||||
for (const job of db.loopJobs) {
|
||||
if (job.status === "RUNNING") {
|
||||
const torrent = torrents.find((t) => t.hash === job.torrentHash);
|
||||
if (!torrent) {
|
||||
try {
|
||||
if (job.torrentFilePath) {
|
||||
await qbit.addTorrentByFile(job.torrentFilePath);
|
||||
} else if (job.magnet) {
|
||||
await qbit.addTorrentByMagnet(job.magnet);
|
||||
}
|
||||
await logJob(job.id, "WARN", "Torrent missing, re-added", "JOB_RESTARTED");
|
||||
} catch (error) {
|
||||
job.status = "ERROR";
|
||||
job.lastError = "Failed to re-add torrent";
|
||||
await logJob(job.id, "ERROR", job.lastError);
|
||||
}
|
||||
job.updatedAt = nowIso();
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
job.currentRun = job.currentRun ?? {
|
||||
startedAt: nowIso(),
|
||||
lastProgress: 0,
|
||||
lastProgressAt: nowIso(),
|
||||
downloadedThisRunBytes: 0,
|
||||
avgSpeed: 0,
|
||||
};
|
||||
if (torrent.progress > job.currentRun.lastProgress) {
|
||||
job.currentRun.lastProgress = torrent.progress;
|
||||
job.currentRun.lastProgressAt = nowIso();
|
||||
job.currentRun.stalledSince = undefined;
|
||||
}
|
||||
job.currentRun.avgSpeed = torrent.dlspeed;
|
||||
job.updatedAt = nowIso();
|
||||
|
||||
const stalledState = /stalled|meta/i.test(torrent.state);
|
||||
if (stalledState) {
|
||||
if (!job.currentRun.stalledSince) {
|
||||
job.currentRun.stalledSince = nowIso();
|
||||
}
|
||||
const lastProgressAt = job.currentRun.lastProgressAt
|
||||
? new Date(job.currentRun.lastProgressAt).getTime()
|
||||
: 0;
|
||||
if (Date.now() - lastProgressAt > config.stalledRecoveryMs) {
|
||||
await logJob(
|
||||
job.id,
|
||||
"WARN",
|
||||
"Stalled recovery: torrent will be removed and re-added"
|
||||
);
|
||||
try {
|
||||
await qbit.deleteTorrent(job.torrentHash, true);
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to delete stalled torrent");
|
||||
}
|
||||
job.status = "WAITING_DELAY";
|
||||
job.nextRunAt = new Date(Date.now() + job.delayMs).toISOString();
|
||||
job.updatedAt = nowIso();
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (torrent.progress >= 1 && /UP|uploading|stalledUP|pausedUP|queuedUP/i.test(torrent.state)) {
|
||||
job.doneLoops += 1;
|
||||
job.totals.totalDownloadedBytes += job.sizeBytes;
|
||||
await logJob(job.id, "INFO", `Loop ${job.doneLoops} completed`, "JOB_COMPLETED_LOOP");
|
||||
try {
|
||||
await qbit.deleteTorrent(job.torrentHash, true);
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to delete torrent");
|
||||
}
|
||||
if (job.doneLoops >= job.targetLoops) {
|
||||
job.status = "COMPLETED";
|
||||
await logJob(job.id, "INFO", "All loops completed", "JOB_COMPLETED_ALL");
|
||||
} else {
|
||||
job.status = "WAITING_DELAY";
|
||||
job.nextRunAt = new Date(Date.now() + job.delayMs).toISOString();
|
||||
}
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (job.status === "WAITING_DELAY") {
|
||||
const nextRunAt = job.nextRunAt ? new Date(job.nextRunAt).getTime() : 0;
|
||||
if (Date.now() >= nextRunAt) {
|
||||
try {
|
||||
if (job.torrentFilePath) {
|
||||
await qbit.addTorrentByFile(job.torrentFilePath);
|
||||
} else if (job.magnet) {
|
||||
await qbit.addTorrentByMagnet(job.magnet);
|
||||
}
|
||||
job.status = "RUNNING";
|
||||
job.currentRun = {
|
||||
startedAt: nowIso(),
|
||||
lastProgress: 0,
|
||||
lastProgressAt: nowIso(),
|
||||
downloadedThisRunBytes: 0,
|
||||
avgSpeed: 0,
|
||||
};
|
||||
await logJob(job.id, "INFO", "Loop restarted", "JOB_RESTARTED");
|
||||
} catch (error) {
|
||||
job.status = "ERROR";
|
||||
job.lastError = "Failed to re-add torrent after delay";
|
||||
await logJob(job.id, "ERROR", job.lastError);
|
||||
}
|
||||
job.updatedAt = nowIso();
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
await writeDb(db);
|
||||
db.loopJobs.forEach((job) => emitJobMetrics(job));
|
||||
}
|
||||
};
|
||||
151
apps/server/src/loop/loop.routes.ts
Normal file
151
apps/server/src/loop/loop.routes.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { Router } from "express";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { getQbitCapabilities, getQbitClient } from "../qbit/qbit.context";
|
||||
import { readDb } from "../storage/jsondb";
|
||||
import { createLoopJob, stopLoopJob } from "./loop.engine";
|
||||
import { dryRunSchema, loopStartSchema } from "../utils/validators";
|
||||
import { getArchiveStatus } from "../torrent/torrent.archive";
|
||||
import { config } from "../config";
|
||||
import { setArchiveStatus } from "../torrent/torrent.archive";
|
||||
import { nowIso } from "../utils/time";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post("/start", async (req, res) => {
|
||||
const parsed = loopStartSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
const { hash, allowIp, targetLoops, delayMs } = parsed.data;
|
||||
const db = await readDb();
|
||||
if (targetLoops > db.settings.maxLoopLimit) {
|
||||
return res.status(400).json({ error: "Target loops exceed max limit" });
|
||||
}
|
||||
const qbit = getQbitClient();
|
||||
const torrents = await qbit.getTorrentsInfo();
|
||||
const torrent = torrents.find((t) => t.hash === hash);
|
||||
if (!torrent) {
|
||||
return res.status(404).json({ error: "Torrent not found" });
|
||||
}
|
||||
let archive = await getArchiveStatus(hash);
|
||||
if (!archive?.torrentFilePath) {
|
||||
try {
|
||||
const buffer = await qbit.exportTorrent(hash);
|
||||
const targetPath = path.join(config.torrentArchiveDir, `${hash}.torrent`);
|
||||
await fs.writeFile(targetPath, buffer);
|
||||
archive = await setArchiveStatus({
|
||||
hash,
|
||||
status: "READY",
|
||||
torrentFilePath: targetPath,
|
||||
source: "exported",
|
||||
updatedAt: nowIso(),
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
error: "Arşiv yok ve export başarısız. Lütfen Advanced bölümünden .torrent yükleyin.",
|
||||
});
|
||||
}
|
||||
}
|
||||
try {
|
||||
await fs.access(archive.torrentFilePath);
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
error: "Arşiv dosyası bulunamadı. Lütfen tekrar yükleyin.",
|
||||
});
|
||||
}
|
||||
const job = await createLoopJob({
|
||||
torrentHash: hash,
|
||||
name: torrent.name,
|
||||
sizeBytes: torrent.size,
|
||||
magnet: undefined,
|
||||
torrentFilePath: archive?.torrentFilePath,
|
||||
allowIp,
|
||||
targetLoops,
|
||||
delayMs,
|
||||
});
|
||||
res.json(job);
|
||||
});
|
||||
|
||||
router.post("/stop/:jobId", async (req, res) => {
|
||||
const { jobId } = req.params;
|
||||
const job = await stopLoopJob(jobId);
|
||||
if (!job) {
|
||||
return res.status(404).json({ error: "Job not found" });
|
||||
}
|
||||
try {
|
||||
const qbit = getQbitClient();
|
||||
await qbit.deleteTorrent(job.torrentHash, true);
|
||||
} catch (error) {
|
||||
// Best-effort delete
|
||||
}
|
||||
res.json(job);
|
||||
});
|
||||
|
||||
router.post("/stop-by-hash", async (req, res) => {
|
||||
const { hash } = req.body ?? {};
|
||||
if (!hash) {
|
||||
return res.status(400).json({ error: "Missing hash" });
|
||||
}
|
||||
const db = await readDb();
|
||||
const job = db.loopJobs.find((j) => j.torrentHash === hash);
|
||||
if (!job) {
|
||||
return res.status(404).json({ error: "Job not found" });
|
||||
}
|
||||
const stopped = await stopLoopJob(job.id);
|
||||
try {
|
||||
const qbit = getQbitClient();
|
||||
await qbit.deleteTorrent(hash, true);
|
||||
} catch (error) {
|
||||
// Best-effort delete
|
||||
}
|
||||
res.json(stopped);
|
||||
});
|
||||
|
||||
router.post("/dry-run", async (req, res) => {
|
||||
const parsed = dryRunSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
const { hash } = parsed.data;
|
||||
const qbit = getQbitClient();
|
||||
const caps = getQbitCapabilities();
|
||||
const torrents = await qbit.getTorrentsInfo();
|
||||
const torrent = torrents.find((t) => t.hash === hash);
|
||||
if (!torrent) {
|
||||
return res.status(404).json({ error: "Torrent not found" });
|
||||
}
|
||||
const archive = await getArchiveStatus(hash);
|
||||
res.json({
|
||||
ok: true,
|
||||
qbitVersion: caps?.version,
|
||||
capabilities: caps,
|
||||
hasMagnet: Boolean(torrent.magnet_uri),
|
||||
archiveStatus: archive?.status ?? "MISSING",
|
||||
warnings: [
|
||||
caps?.hasPeersEndpoint ? null : "Peer listing unsupported",
|
||||
caps?.hasBanEndpoint ? null : "Peer ban unsupported; warn-only enforcement",
|
||||
torrent.magnet_uri ? null : "Magnet unavailable; upload .torrent recommended",
|
||||
].filter(Boolean),
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/jobs", async (_req, res) => {
|
||||
const db = await readDb();
|
||||
res.json(db.loopJobs);
|
||||
});
|
||||
|
||||
router.get("/job/:jobId", async (req, res) => {
|
||||
const db = await readDb();
|
||||
const job = db.loopJobs.find((j) => j.id === req.params.jobId);
|
||||
if (!job) {
|
||||
return res.status(404).json({ error: "Job not found" });
|
||||
}
|
||||
res.json(job);
|
||||
});
|
||||
|
||||
router.get("/logs/:jobId", async (req, res) => {
|
||||
res.json({ jobId: req.params.jobId, logs: [] });
|
||||
});
|
||||
|
||||
export default router;
|
||||
26
apps/server/src/loop/loop.scheduler.ts
Normal file
26
apps/server/src/loop/loop.scheduler.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { QbitClient } from "../qbit/qbit.client"
|
||||
import { tickLoopJobs } from "./loop.engine"
|
||||
import { getStatusSnapshot, refreshJobsStatus, setTorrentsStatus } from "../status/status.service"
|
||||
import { emitStatusUpdate } from "../realtime/emitter"
|
||||
import { logger } from "../utils/logger"
|
||||
|
||||
export const startLoopScheduler = (qbit: QbitClient, intervalMs: number) => {
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const torrents = await qbit.getTorrentsInfo();
|
||||
const transfer = await qbit.getTransferInfo();
|
||||
setTorrentsStatus(torrents, transfer);
|
||||
await tickLoopJobs(qbit, torrents);
|
||||
const jobs = await refreshJobsStatus();
|
||||
const current = await getStatusSnapshot();
|
||||
emitStatusUpdate({
|
||||
qbit: { ...current.qbit, ok: true },
|
||||
torrents,
|
||||
transfer,
|
||||
jobs,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Loop scheduler tick failed");
|
||||
}
|
||||
}, intervalMs);
|
||||
};
|
||||
12
apps/server/src/loop/loop.types.ts
Normal file
12
apps/server/src/loop/loop.types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { LoopJob } from "../types"
|
||||
|
||||
export interface LoopStartInput {
|
||||
hash: string;
|
||||
allowIp: string;
|
||||
targetLoops: number;
|
||||
delayMs: number;
|
||||
}
|
||||
|
||||
export interface LoopEngineContext {
|
||||
jobs: LoopJob[];
|
||||
}
|
||||
74
apps/server/src/loop/profiles.routes.ts
Normal file
74
apps/server/src/loop/profiles.routes.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Router } from "express";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readDb, writeDb } from "../storage/jsondb"
|
||||
import { profileSchema } from "../utils/validators"
|
||||
import { nowIso } from "../utils/time"
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", async (_req, res) => {
|
||||
const db = await readDb();
|
||||
res.json(db.profiles);
|
||||
});
|
||||
|
||||
router.post("/", async (req, res) => {
|
||||
const parsed = profileSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
const db = await readDb();
|
||||
const profile = {
|
||||
id: randomUUID(),
|
||||
createdAt: nowIso(),
|
||||
...parsed.data,
|
||||
};
|
||||
db.profiles.push(profile);
|
||||
await writeDb(db);
|
||||
res.json(profile);
|
||||
});
|
||||
|
||||
router.put("/:profileId", async (req, res) => {
|
||||
const parsed = profileSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
const db = await readDb();
|
||||
const index = db.profiles.findIndex((p) => p.id === req.params.profileId);
|
||||
if (index === -1) {
|
||||
return res.status(404).json({ error: "Profile not found" });
|
||||
}
|
||||
db.profiles[index] = {
|
||||
...db.profiles[index],
|
||||
...parsed.data,
|
||||
};
|
||||
await writeDb(db);
|
||||
res.json(db.profiles[index]);
|
||||
});
|
||||
|
||||
router.delete("/:profileId", async (req, res) => {
|
||||
const db = await readDb();
|
||||
const next = db.profiles.filter((p) => p.id !== req.params.profileId);
|
||||
if (next.length === db.profiles.length) {
|
||||
return res.status(404).json({ error: "Profile not found" });
|
||||
}
|
||||
db.profiles = next;
|
||||
await writeDb(db);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post("/apply", async (req, res) => {
|
||||
const { profileId, hash } = req.body ?? {};
|
||||
const db = await readDb();
|
||||
const profile = db.profiles.find((p) => p.id === profileId);
|
||||
if (!profile) {
|
||||
return res.status(404).json({ error: "Profile not found" });
|
||||
}
|
||||
res.json({
|
||||
hash,
|
||||
allowIp: profile.allowIp,
|
||||
delayMs: profile.delayMs,
|
||||
targetLoops: profile.targetLoops,
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
30
apps/server/src/qbit/qbit.capabilities.ts
Normal file
30
apps/server/src/qbit/qbit.capabilities.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { QbitClient } from "./qbit.client"
|
||||
import { QbitCapabilities } from "./qbit.types"
|
||||
|
||||
export const detectCapabilities = async (
|
||||
client: QbitClient
|
||||
): Promise<QbitCapabilities> => {
|
||||
const version = await client.getVersion();
|
||||
let hasPeersEndpoint = true;
|
||||
let hasBanEndpoint = true;
|
||||
|
||||
try {
|
||||
await client.getTorrentPeers("__probe__");
|
||||
} catch (error) {
|
||||
const status = (error as any)?.response?.status;
|
||||
if (status !== 400 && status !== 404) {
|
||||
hasPeersEndpoint = false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await client.banPeers(["127.0.0.1:1"]);
|
||||
} catch (error) {
|
||||
const status = (error as any)?.response?.status;
|
||||
if (status !== 400 && status !== 404) {
|
||||
hasBanEndpoint = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { version, hasPeersEndpoint, hasBanEndpoint };
|
||||
};
|
||||
177
apps/server/src/qbit/qbit.client.ts
Normal file
177
apps/server/src/qbit/qbit.client.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import { CookieJar } from "tough-cookie";
|
||||
import { wrapper } from "axios-cookiejar-support";
|
||||
import FormData from "form-data";
|
||||
import fs from "node:fs";
|
||||
import { config } from "../config"
|
||||
import { logger } from "../utils/logger"
|
||||
import {
|
||||
QbitPeerList,
|
||||
QbitTorrentInfo,
|
||||
QbitTorrentProperties,
|
||||
QbitTransferInfo,
|
||||
} from "./qbit.types"
|
||||
|
||||
export class QbitClient {
|
||||
private client: AxiosInstance;
|
||||
private jar: CookieJar;
|
||||
private loggedIn = false;
|
||||
|
||||
constructor() {
|
||||
this.jar = new CookieJar();
|
||||
this.client = wrapper(
|
||||
axios.create({
|
||||
baseURL: config.qbitBaseUrl,
|
||||
jar: this.jar,
|
||||
withCredentials: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async login(): Promise<void> {
|
||||
if (!config.qbitBaseUrl) {
|
||||
throw new Error("QBIT_BASE_URL missing");
|
||||
}
|
||||
const form = new URLSearchParams();
|
||||
form.append("username", config.qbitUsername);
|
||||
form.append("password", config.qbitPassword);
|
||||
await this.client.post("/api/v2/auth/login", form, {
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
});
|
||||
this.loggedIn = true;
|
||||
}
|
||||
|
||||
private async request<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
if (!this.loggedIn) {
|
||||
await this.login();
|
||||
}
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
if (
|
||||
axios.isAxiosError(error) &&
|
||||
(error.response?.status === 401 || error.response?.status === 403)
|
||||
) {
|
||||
logger.warn("qBittorrent session expired, re-login");
|
||||
this.loggedIn = false;
|
||||
await this.login();
|
||||
return await fn();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getVersion(): Promise<string> {
|
||||
const response = await this.request(() =>
|
||||
this.client.get<string>("/api/v2/app/version")
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getTorrentsInfo(): Promise<QbitTorrentInfo[]> {
|
||||
const response = await this.request(() =>
|
||||
this.client.get<QbitTorrentInfo[]>("/api/v2/torrents/info", {
|
||||
params: { filter: "all" },
|
||||
})
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getTransferInfo(): Promise<QbitTransferInfo> {
|
||||
const response = await this.request(() =>
|
||||
this.client.get<QbitTransferInfo>("/api/v2/transfer/info")
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getTorrentProperties(hash: string): Promise<QbitTorrentProperties> {
|
||||
const response = await this.request(() =>
|
||||
this.client.get<QbitTorrentProperties>("/api/v2/torrents/properties", {
|
||||
params: { hash },
|
||||
})
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getTorrentPeers(hash: string): Promise<QbitPeerList> {
|
||||
const response = await this.request(() =>
|
||||
this.client.get<QbitPeerList>("/api/v2/sync/torrentPeers", {
|
||||
params: { hash },
|
||||
})
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async exportTorrent(hash: string): Promise<Buffer> {
|
||||
const response = await this.request(() =>
|
||||
this.client.get<ArrayBuffer>("/api/v2/torrents/export", {
|
||||
params: { hashes: hash },
|
||||
responseType: "arraybuffer",
|
||||
})
|
||||
);
|
||||
return Buffer.from(response.data);
|
||||
}
|
||||
|
||||
async addTorrentByMagnet(magnet: string, options: Record<string, string> = {}) {
|
||||
const form = new URLSearchParams();
|
||||
form.append("urls", magnet);
|
||||
Object.entries(options).forEach(([key, value]) => form.append(key, value));
|
||||
await this.request(() =>
|
||||
this.client.post("/api/v2/torrents/add", form, {
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async addTorrentByFile(filePath: string, options: Record<string, string> = {}) {
|
||||
const form = new FormData();
|
||||
form.append("torrents", fs.createReadStream(filePath));
|
||||
Object.entries(options).forEach(([key, value]) => form.append(key, value));
|
||||
await this.request(() =>
|
||||
this.client.post("/api/v2/torrents/add", form, {
|
||||
headers: form.getHeaders(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async deleteTorrent(hash: string, deleteFiles = true) {
|
||||
const form = new URLSearchParams();
|
||||
form.append("hashes", hash);
|
||||
form.append("deleteFiles", deleteFiles ? "true" : "false");
|
||||
await this.request(() =>
|
||||
this.client.post("/api/v2/torrents/delete", form, {
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async pauseTorrent(hash: string) {
|
||||
const form = new URLSearchParams();
|
||||
form.append("hashes", hash);
|
||||
await this.request(() =>
|
||||
this.client.post("/api/v2/torrents/pause", form, {
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async resumeTorrent(hash: string) {
|
||||
const form = new URLSearchParams();
|
||||
form.append("hashes", hash);
|
||||
await this.request(() =>
|
||||
this.client.post("/api/v2/torrents/resume", form, {
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async banPeers(peers: string[]) {
|
||||
const form = new URLSearchParams();
|
||||
form.append("peers", peers.join("|"));
|
||||
await this.request(() =>
|
||||
this.client.post("/api/v2/transfer/banPeers", form, {
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
22
apps/server/src/qbit/qbit.context.ts
Normal file
22
apps/server/src/qbit/qbit.context.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { QbitClient } from "./qbit.client"
|
||||
import { QbitCapabilities } from "./qbit.types"
|
||||
|
||||
let client: QbitClient | null = null;
|
||||
let capabilities: QbitCapabilities | null = null;
|
||||
|
||||
export const setQbitClient = (instance: QbitClient) => {
|
||||
client = instance;
|
||||
};
|
||||
|
||||
export const getQbitClient = () => {
|
||||
if (!client) {
|
||||
throw new Error("Qbit client not initialized");
|
||||
}
|
||||
return client;
|
||||
};
|
||||
|
||||
export const setQbitCapabilities = (caps: QbitCapabilities) => {
|
||||
capabilities = caps;
|
||||
};
|
||||
|
||||
export const getQbitCapabilities = () => capabilities;
|
||||
30
apps/server/src/qbit/qbit.routes.ts
Normal file
30
apps/server/src/qbit/qbit.routes.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Router } from "express";
|
||||
import { getQbitClient } from "./qbit.context"
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/torrents", async (_req, res) => {
|
||||
const qbit = getQbitClient();
|
||||
const torrents = await qbit.getTorrentsInfo();
|
||||
res.json(torrents);
|
||||
});
|
||||
|
||||
router.get("/transfer", async (_req, res) => {
|
||||
const qbit = getQbitClient();
|
||||
const transfer = await qbit.getTransferInfo();
|
||||
res.json(transfer);
|
||||
});
|
||||
|
||||
router.get("/torrent/:hash", async (req, res) => {
|
||||
const qbit = getQbitClient();
|
||||
const props = await qbit.getTorrentProperties(req.params.hash);
|
||||
res.json(props);
|
||||
});
|
||||
|
||||
router.delete("/torrent/:hash", async (req, res) => {
|
||||
const qbit = getQbitClient();
|
||||
await qbit.deleteTorrent(req.params.hash, true);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
51
apps/server/src/qbit/qbit.types.ts
Normal file
51
apps/server/src/qbit/qbit.types.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export interface QbitTorrentInfo {
|
||||
hash: string;
|
||||
name: string;
|
||||
size: number;
|
||||
progress: number;
|
||||
dlspeed: number;
|
||||
state: string;
|
||||
magnet_uri?: string;
|
||||
completed?: number;
|
||||
tags?: string;
|
||||
category?: string;
|
||||
tracker?: string;
|
||||
seeding_time?: number;
|
||||
uploaded?: number;
|
||||
}
|
||||
|
||||
export interface QbitTransferInfo {
|
||||
dl_info_speed: number;
|
||||
dl_info_data: number;
|
||||
up_info_speed: number;
|
||||
up_info_data: number;
|
||||
connection_status: string;
|
||||
}
|
||||
|
||||
export interface QbitTorrentProperties {
|
||||
save_path?: string;
|
||||
completion_on?: number;
|
||||
comment?: string;
|
||||
total_size?: number;
|
||||
piece_size?: number;
|
||||
}
|
||||
|
||||
export interface QbitPeerList {
|
||||
peers: Record<
|
||||
string,
|
||||
{
|
||||
ip: string;
|
||||
port: number;
|
||||
client: string;
|
||||
progress: number;
|
||||
dl_speed: number;
|
||||
up_speed: number;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
export interface QbitCapabilities {
|
||||
version: string;
|
||||
hasPeersEndpoint: boolean;
|
||||
hasBanEndpoint: boolean;
|
||||
}
|
||||
43
apps/server/src/realtime/emitter.ts
Normal file
43
apps/server/src/realtime/emitter.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Server } from "socket.io";
|
||||
import { EVENTS } from "./events";
|
||||
import { StatusSnapshot } from "../status/status.service";
|
||||
import { LoopJob, TimerLog, TimerSummary } from "../types";
|
||||
|
||||
let io: Server | null = null;
|
||||
|
||||
export const initEmitter = (server: Server) => {
|
||||
io = server;
|
||||
};
|
||||
|
||||
export const emitStatusSnapshot = (snapshot: StatusSnapshot) => {
|
||||
io?.emit(EVENTS.STATUS_SNAPSHOT, snapshot);
|
||||
};
|
||||
|
||||
export const emitStatusUpdate = (snapshot: StatusSnapshot) => {
|
||||
io?.emit(EVENTS.STATUS_UPDATE, snapshot);
|
||||
};
|
||||
|
||||
export const emitJobMetrics = (job: LoopJob) => {
|
||||
io?.emit(EVENTS.JOB_METRICS, job);
|
||||
};
|
||||
|
||||
export const emitJobLog = (payload: {
|
||||
jobId: string;
|
||||
level: "INFO" | "WARN" | "ERROR";
|
||||
message: string;
|
||||
createdAt: string;
|
||||
}) => {
|
||||
io?.emit(EVENTS.JOB_LOG, payload);
|
||||
};
|
||||
|
||||
export const emitQbitHealth = (payload: StatusSnapshot["qbit"]) => {
|
||||
io?.emit(EVENTS.QBIT_HEALTH, payload);
|
||||
};
|
||||
|
||||
export const emitTimerLog = (payload: TimerLog) => {
|
||||
io?.emit(EVENTS.TIMER_LOG, payload);
|
||||
};
|
||||
|
||||
export const emitTimerSummary = (payload: TimerSummary) => {
|
||||
io?.emit(EVENTS.TIMER_SUMMARY, payload);
|
||||
};
|
||||
9
apps/server/src/realtime/events.ts
Normal file
9
apps/server/src/realtime/events.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const EVENTS = {
|
||||
STATUS_SNAPSHOT: "status:snapshot",
|
||||
STATUS_UPDATE: "status:update",
|
||||
JOB_METRICS: "job:metrics",
|
||||
JOB_LOG: "job:log",
|
||||
QBIT_HEALTH: "qbit:health",
|
||||
TIMER_LOG: "timer:log",
|
||||
TIMER_SUMMARY: "timer:summary",
|
||||
};
|
||||
59
apps/server/src/realtime/socket.ts
Normal file
59
apps/server/src/realtime/socket.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Server } from "socket.io";
|
||||
import http from "node:http";
|
||||
import { verifyToken } from "../auth/auth.service";
|
||||
import { getStatusSnapshot } from "../status/status.service";
|
||||
import { EVENTS } from "./events";
|
||||
import { config, isDev } from "../config";
|
||||
|
||||
const parseCookies = (cookieHeader?: string) => {
|
||||
const cookies: Record<string, string> = {};
|
||||
if (!cookieHeader) {
|
||||
return cookies;
|
||||
}
|
||||
cookieHeader.split(";").forEach((part) => {
|
||||
const [key, ...rest] = part.trim().split("=");
|
||||
cookies[key] = decodeURIComponent(rest.join("="));
|
||||
});
|
||||
return cookies;
|
||||
};
|
||||
|
||||
export const createSocketServer = (server: http.Server) => {
|
||||
const io = new Server(server, {
|
||||
cors: isDev
|
||||
? {
|
||||
origin: true,
|
||||
credentials: true,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
io.use((socket, next) => {
|
||||
const authToken = socket.handshake.auth?.token as string | undefined;
|
||||
if (authToken) {
|
||||
try {
|
||||
verifyToken(authToken);
|
||||
return next();
|
||||
} catch (error) {
|
||||
return next(new Error("Unauthorized"));
|
||||
}
|
||||
}
|
||||
const cookies = parseCookies(socket.request.headers.cookie);
|
||||
const token = cookies["qbuffer_token"];
|
||||
if (!token) {
|
||||
return next(new Error("Unauthorized"));
|
||||
}
|
||||
try {
|
||||
verifyToken(token);
|
||||
return next();
|
||||
} catch (error) {
|
||||
return next(new Error("Unauthorized"));
|
||||
}
|
||||
});
|
||||
|
||||
io.on("connection", async (socket) => {
|
||||
const snapshot = await getStatusSnapshot();
|
||||
socket.emit(EVENTS.STATUS_SNAPSHOT, snapshot);
|
||||
});
|
||||
|
||||
return io;
|
||||
};
|
||||
11
apps/server/src/status/status.routes.ts
Normal file
11
apps/server/src/status/status.routes.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Router } from "express";
|
||||
import { getStatusSnapshot } from "./status.service"
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", async (_req, res) => {
|
||||
const snapshot = await getStatusSnapshot();
|
||||
res.json(snapshot);
|
||||
});
|
||||
|
||||
export default router;
|
||||
45
apps/server/src/status/status.service.ts
Normal file
45
apps/server/src/status/status.service.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { DbSchema, LoopJob } from "../types"
|
||||
import { QbitCapabilities, QbitTorrentInfo, QbitTransferInfo } from "../qbit/qbit.types"
|
||||
import { readDb } from "../storage/jsondb"
|
||||
|
||||
export interface StatusSnapshot {
|
||||
qbit: {
|
||||
ok: boolean;
|
||||
version?: string;
|
||||
capabilities?: QbitCapabilities;
|
||||
lastError?: string;
|
||||
};
|
||||
torrents: QbitTorrentInfo[];
|
||||
transfer?: QbitTransferInfo;
|
||||
jobs: LoopJob[];
|
||||
}
|
||||
|
||||
let snapshot: StatusSnapshot = {
|
||||
qbit: { ok: false },
|
||||
torrents: [],
|
||||
transfer: undefined,
|
||||
jobs: [],
|
||||
};
|
||||
|
||||
export const setQbitStatus = (status: StatusSnapshot["qbit"]) => {
|
||||
snapshot.qbit = status;
|
||||
};
|
||||
|
||||
export const setTorrentsStatus = (
|
||||
torrents: QbitTorrentInfo[],
|
||||
transfer?: QbitTransferInfo
|
||||
) => {
|
||||
snapshot.torrents = torrents;
|
||||
snapshot.transfer = transfer;
|
||||
};
|
||||
|
||||
export const refreshJobsStatus = async () => {
|
||||
const db: DbSchema = await readDb();
|
||||
snapshot.jobs = db.loopJobs;
|
||||
return snapshot.jobs;
|
||||
};
|
||||
|
||||
export const getStatusSnapshot = async (): Promise<StatusSnapshot> => {
|
||||
await refreshJobsStatus();
|
||||
return snapshot;
|
||||
};
|
||||
90
apps/server/src/storage/jsondb.ts
Normal file
90
apps/server/src/storage/jsondb.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { config } from "../config"
|
||||
import { DbSchema } from "../types"
|
||||
import { Mutex } from "./mutex"
|
||||
|
||||
const mutex = new Mutex();
|
||||
|
||||
const defaultDb = (): DbSchema => ({
|
||||
users: [],
|
||||
settings: {
|
||||
pollIntervalMs: config.pollIntervalMs,
|
||||
enforceIntervalMs: config.enforceIntervalMs,
|
||||
defaultDelayMs: config.defaultDelayMs,
|
||||
maxLoopLimit: config.maxLoopLimit,
|
||||
},
|
||||
loopJobs: [],
|
||||
profiles: [],
|
||||
auditLogs: [],
|
||||
archives: {},
|
||||
timerRules: [],
|
||||
timerLogs: [],
|
||||
timerSummary: {
|
||||
totalDeleted: 0,
|
||||
totalSeededSeconds: 0,
|
||||
totalUploadedBytes: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
const rotateBackups = async (dbPath: string) => {
|
||||
const dir = path.dirname(dbPath);
|
||||
const base = path.basename(dbPath);
|
||||
for (let i = 2; i >= 0; i -= 1) {
|
||||
const src = path.join(dir, `${base}.bak${i}`);
|
||||
const dest = path.join(dir, `${base}.bak${i + 1}`);
|
||||
try {
|
||||
await fs.rename(src, dest);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await fs.copyFile(dbPath, path.join(dir, `${base}.bak0`));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const writeRaw = async (data: DbSchema) => {
|
||||
await rotateBackups(config.dbPath);
|
||||
const tempPath = `${config.dbPath}.tmp`;
|
||||
await fs.writeFile(tempPath, JSON.stringify(data, null, 2), "utf-8");
|
||||
await fs.rename(tempPath, config.dbPath);
|
||||
};
|
||||
|
||||
export const readDb = async (): Promise<DbSchema> => {
|
||||
return mutex.run(async () => {
|
||||
try {
|
||||
const content = await fs.readFile(config.dbPath, "utf-8");
|
||||
const parsed = JSON.parse(content) as DbSchema;
|
||||
parsed.timerRules ??= [];
|
||||
parsed.timerLogs ??= [];
|
||||
parsed.timerSummary ??= {
|
||||
totalDeleted: 0,
|
||||
totalSeededSeconds: 0,
|
||||
totalUploadedBytes: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
const initial = defaultDb();
|
||||
await writeRaw(initial);
|
||||
return initial;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const writeDb = async (data: DbSchema): Promise<void> => {
|
||||
await mutex.run(async () => {
|
||||
await writeRaw(data);
|
||||
});
|
||||
};
|
||||
12
apps/server/src/storage/mutex.ts
Normal file
12
apps/server/src/storage/mutex.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export class Mutex {
|
||||
private current: Promise<void> = Promise.resolve();
|
||||
|
||||
async run<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const next = this.current.then(fn, fn);
|
||||
this.current = next.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
7
apps/server/src/storage/paths.ts
Normal file
7
apps/server/src/storage/paths.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { config } from "../config"
|
||||
|
||||
export const ensureDataPaths = async () => {
|
||||
await fs.mkdir(config.dataDir, { recursive: true });
|
||||
await fs.mkdir(config.torrentArchiveDir, { recursive: true });
|
||||
};
|
||||
58
apps/server/src/timer/timer.routes.ts
Normal file
58
apps/server/src/timer/timer.routes.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Router } from "express";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readDb, writeDb } from "../storage/jsondb";
|
||||
import { TimerRule } from "../types";
|
||||
import { nowIso } from "../utils/time";
|
||||
import { z } from "zod";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const ruleSchema = z.object({
|
||||
tags: z.array(z.string().min(1)).min(1),
|
||||
seedLimitSeconds: z.number().int().min(60).max(60 * 60 * 24 * 365),
|
||||
});
|
||||
|
||||
router.get("/rules", async (_req, res) => {
|
||||
const db = await readDb();
|
||||
res.json(db.timerRules ?? []);
|
||||
});
|
||||
|
||||
router.post("/rules", async (req, res) => {
|
||||
const parsed = ruleSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
const db = await readDb();
|
||||
const rule: TimerRule = {
|
||||
id: randomUUID(),
|
||||
tags: parsed.data.tags,
|
||||
seedLimitSeconds: parsed.data.seedLimitSeconds,
|
||||
createdAt: nowIso(),
|
||||
};
|
||||
db.timerRules = [...(db.timerRules ?? []), rule];
|
||||
await writeDb(db);
|
||||
res.json(rule);
|
||||
});
|
||||
|
||||
router.delete("/rules/:ruleId", async (req, res) => {
|
||||
const db = await readDb();
|
||||
const next = (db.timerRules ?? []).filter((rule) => rule.id !== req.params.ruleId);
|
||||
if (next.length === (db.timerRules ?? []).length) {
|
||||
return res.status(404).json({ error: "Rule not found" });
|
||||
}
|
||||
db.timerRules = next;
|
||||
await writeDb(db);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get("/logs", async (_req, res) => {
|
||||
const db = await readDb();
|
||||
res.json(db.timerLogs ?? []);
|
||||
});
|
||||
|
||||
router.get("/summary", async (_req, res) => {
|
||||
const db = await readDb();
|
||||
res.json(db.timerSummary ?? null);
|
||||
});
|
||||
|
||||
export default router;
|
||||
4
apps/server/src/timer/timer.types.ts
Normal file
4
apps/server/src/timer/timer.types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface TimerRuleInput {
|
||||
tags: string[];
|
||||
seedLimitSeconds: number;
|
||||
}
|
||||
94
apps/server/src/timer/timer.worker.ts
Normal file
94
apps/server/src/timer/timer.worker.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { QbitClient } from "../qbit/qbit.client";
|
||||
import { readDb, writeDb } from "../storage/jsondb";
|
||||
import { TimerLog, TimerSummary } from "../types";
|
||||
import { emitTimerLog, emitTimerSummary } from "../realtime/emitter";
|
||||
import { nowIso } from "../utils/time";
|
||||
|
||||
const MAX_LOGS = 2000;
|
||||
|
||||
const normalizeTags = (tags?: string, category?: string) => {
|
||||
const tagList = tags ? tags.split(",").map((tag) => tag.trim()).filter(Boolean) : [];
|
||||
if (category) {
|
||||
tagList.push(category);
|
||||
}
|
||||
return Array.from(new Set(tagList.map((tag) => tag.toLowerCase())));
|
||||
};
|
||||
|
||||
export const startTimerWorker = (qbit: QbitClient, intervalMs: number) => {
|
||||
setInterval(async () => {
|
||||
const db = await readDb();
|
||||
const rules = db.timerRules ?? [];
|
||||
if (rules.length === 0) {
|
||||
return;
|
||||
}
|
||||
const torrents = await qbit.getTorrentsInfo();
|
||||
let summary: TimerSummary =
|
||||
db.timerSummary ?? {
|
||||
totalDeleted: 0,
|
||||
totalSeededSeconds: 0,
|
||||
totalUploadedBytes: 0,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
|
||||
const logs: TimerLog[] = [];
|
||||
|
||||
for (const torrent of torrents) {
|
||||
const tags = normalizeTags(torrent.tags, torrent.category);
|
||||
const addedOnMs = Number(torrent.added_on ?? 0) * 1000;
|
||||
const matchingRules = rules.filter((rule) => {
|
||||
const ruleCreatedAtMs = Date.parse(rule.createdAt);
|
||||
if (Number.isFinite(ruleCreatedAtMs) && addedOnMs > 0) {
|
||||
if (addedOnMs < ruleCreatedAtMs) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return rule.tags.some((tag) => tags.includes(tag.toLowerCase()));
|
||||
});
|
||||
if (matchingRules.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const matchingRule = matchingRules.reduce((best, current) =>
|
||||
current.seedLimitSeconds < best.seedLimitSeconds ? current : best
|
||||
);
|
||||
const seedingSeconds = Number(torrent.seeding_time ?? 0);
|
||||
if (seedingSeconds < matchingRule.seedLimitSeconds) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await qbit.deleteTorrent(torrent.hash, true);
|
||||
} catch (error) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const logEntry: TimerLog = {
|
||||
id: randomUUID(),
|
||||
hash: torrent.hash,
|
||||
name: torrent.name,
|
||||
sizeBytes: torrent.size,
|
||||
tracker: torrent.tracker,
|
||||
tags,
|
||||
category: torrent.category,
|
||||
seedingTimeSeconds: seedingSeconds,
|
||||
uploadedBytes: torrent.uploaded ?? 0,
|
||||
deletedAt: nowIso(),
|
||||
};
|
||||
logs.push(logEntry);
|
||||
summary = {
|
||||
totalDeleted: summary.totalDeleted + 1,
|
||||
totalSeededSeconds: summary.totalSeededSeconds + seedingSeconds,
|
||||
totalUploadedBytes: summary.totalUploadedBytes + (torrent.uploaded ?? 0),
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
emitTimerLog(logEntry);
|
||||
emitTimerSummary(summary);
|
||||
}
|
||||
|
||||
if (logs.length > 0) {
|
||||
db.timerLogs = [...(db.timerLogs ?? []), ...logs].slice(-MAX_LOGS);
|
||||
db.timerSummary = summary;
|
||||
await writeDb(db);
|
||||
}
|
||||
}, intervalMs);
|
||||
};
|
||||
46
apps/server/src/torrent/torrent.archive.ts
Normal file
46
apps/server/src/torrent/torrent.archive.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { readDb, writeDb } from "../storage/jsondb";
|
||||
import { ArchiveStatus } from "../types";
|
||||
import { nowIso } from "../utils/time";
|
||||
import { config } from "../config";
|
||||
|
||||
export const setArchiveStatus = async (status: ArchiveStatus) => {
|
||||
const db = await readDb();
|
||||
db.archives[status.hash] = status;
|
||||
await writeDb(db);
|
||||
return status;
|
||||
};
|
||||
|
||||
export const getArchiveStatus = async (hash: string) => {
|
||||
const db = await readDb();
|
||||
const existing = db.archives[hash];
|
||||
const torrentPath = path.join(config.torrentArchiveDir, `${hash}.torrent`);
|
||||
try {
|
||||
await fs.access(torrentPath);
|
||||
if (!existing || existing.status !== "READY") {
|
||||
const updated: ArchiveStatus = {
|
||||
hash,
|
||||
status: "READY",
|
||||
torrentFilePath: torrentPath,
|
||||
source: existing?.source ?? "manual",
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
db.archives[hash] = updated;
|
||||
await writeDb(db);
|
||||
return updated;
|
||||
}
|
||||
} catch (error) {
|
||||
// File does not exist; fall back to stored status.
|
||||
}
|
||||
return existing;
|
||||
};
|
||||
|
||||
export const createPendingArchive = async (hash: string) => {
|
||||
const status: ArchiveStatus = {
|
||||
hash,
|
||||
status: "PENDING",
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
return setArchiveStatus(status);
|
||||
};
|
||||
41
apps/server/src/torrent/torrent.generator.ts
Normal file
41
apps/server/src/torrent/torrent.generator.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { config } from "../config";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export const generateTorrentFile = async (
|
||||
magnet: string,
|
||||
hash: string
|
||||
): Promise<string> => {
|
||||
const targetPath = path.join(config.torrentArchiveDir, `${hash}.torrent`);
|
||||
const { default: WebTorrent } = await import("webtorrent");
|
||||
const client = new WebTorrent();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const torrent = client.add(magnet, { path: config.dataDir });
|
||||
const timeout = setTimeout(() => {
|
||||
client.destroy();
|
||||
reject(new Error("Metadata fetch timeout"));
|
||||
}, 120_000);
|
||||
|
||||
torrent.on("metadata", async () => {
|
||||
clearTimeout(timeout);
|
||||
try {
|
||||
const buffer = torrent.torrentFile;
|
||||
await fs.writeFile(targetPath, buffer);
|
||||
resolve(targetPath);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
} finally {
|
||||
client.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
torrent.on("error", (error) => {
|
||||
logger.error({ error }, "Torrent metadata error");
|
||||
clearTimeout(timeout);
|
||||
client.destroy();
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
};
|
||||
121
apps/server/src/torrent/torrent.routes.ts
Normal file
121
apps/server/src/torrent/torrent.routes.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Router } from "express";
|
||||
import multer from "multer";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import { getQbitClient } from "../qbit/qbit.context";
|
||||
import { getArchiveStatus, setArchiveStatus } from "./torrent.archive";
|
||||
import { nowIso } from "../utils/time";
|
||||
import { appendAuditLog, logger } from "../utils/logger";
|
||||
import { config } from "../config";
|
||||
|
||||
const router = Router();
|
||||
const upload = multer({ dest: "/tmp" });
|
||||
|
||||
router.post("/select", async (req, res) => {
|
||||
const { hash } = req.body ?? {};
|
||||
if (!hash) {
|
||||
return res.status(400).json({ error: "Missing hash" });
|
||||
}
|
||||
const existing = await getArchiveStatus(hash);
|
||||
if (existing?.status === "READY") {
|
||||
return res.json({ ok: true, hash, archive: existing });
|
||||
}
|
||||
const qbit = getQbitClient();
|
||||
const torrents = await qbit.getTorrentsInfo();
|
||||
const torrent = torrents.find((t) => t.hash === hash);
|
||||
if (!torrent) {
|
||||
return res.status(404).json({ error: "Torrent not found" });
|
||||
}
|
||||
|
||||
await setArchiveStatus({
|
||||
hash,
|
||||
status: "MISSING",
|
||||
updatedAt: nowIso(),
|
||||
});
|
||||
|
||||
res.json({ ok: true, hash, archive: { status: "MISSING" } });
|
||||
});
|
||||
|
||||
router.post("/archive/from-selected", async (req, res) => {
|
||||
const { hash } = req.body ?? {};
|
||||
if (!hash) {
|
||||
return res.status(400).json({ error: "Missing hash" });
|
||||
}
|
||||
const existing = await getArchiveStatus(hash);
|
||||
if (existing?.status === "READY") {
|
||||
return res.json({ ok: true, torrentFilePath: existing.torrentFilePath, source: existing.source });
|
||||
}
|
||||
await setArchiveStatus({
|
||||
hash,
|
||||
status: "MISSING",
|
||||
lastError: "Magnet export disabled; upload .torrent manually.",
|
||||
updatedAt: nowIso(),
|
||||
});
|
||||
await appendAuditLog({
|
||||
level: "WARN",
|
||||
event: "ARCHIVE_FAIL",
|
||||
message: `Archive generation disabled for ${hash}; manual upload required`,
|
||||
});
|
||||
return res.status(400).json({ error: "Magnet export disabled; upload .torrent manually." });
|
||||
});
|
||||
|
||||
router.post("/archive/upload", upload.single("file"), async (req, res) => {
|
||||
const { hash } = req.body ?? {};
|
||||
if (!hash || !req.file) {
|
||||
return res.status(400).json({ error: "Missing hash or file" });
|
||||
}
|
||||
const inputHash = String(hash).toLowerCase();
|
||||
const buffer = await fs.readFile(req.file.path);
|
||||
let warning: string | undefined;
|
||||
try {
|
||||
const { default: parseTorrent } = await import("parse-torrent");
|
||||
const parsed = parseTorrent(buffer);
|
||||
const infoHash = String(parsed.infoHash ?? "").toLowerCase();
|
||||
if (infoHash && infoHash !== inputHash) {
|
||||
await fs.unlink(req.file.path);
|
||||
return res.status(400).json({
|
||||
error: "Torrent hash uyuşmuyor. Doğru .torrent dosyasını seçin.",
|
||||
expected: inputHash,
|
||||
actual: infoHash,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
warning = "Torrent dosyası okunamadı; yine de arşive kaydedildi.";
|
||||
logger.warn({ error, hash: inputHash }, "Torrent parse failed; storing archive anyway");
|
||||
}
|
||||
const targetPath = path.join(config.torrentArchiveDir, `${hash}.torrent`);
|
||||
await fs.writeFile(targetPath, buffer);
|
||||
await fs.unlink(req.file.path);
|
||||
await setArchiveStatus({
|
||||
hash,
|
||||
status: "READY",
|
||||
torrentFilePath: targetPath,
|
||||
source: "manual",
|
||||
updatedAt: nowIso(),
|
||||
});
|
||||
try {
|
||||
const qbit = getQbitClient();
|
||||
await qbit.addTorrentByFile(targetPath);
|
||||
return res.json({
|
||||
ok: true,
|
||||
torrentFilePath: targetPath,
|
||||
added: true,
|
||||
});
|
||||
} catch (error) {
|
||||
return res.json({
|
||||
ok: true,
|
||||
torrentFilePath: targetPath,
|
||||
added: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/archive/status/:hash", async (req, res) => {
|
||||
const status = await getArchiveStatus(req.params.hash);
|
||||
if (!status) {
|
||||
return res.json({ hash: req.params.hash, status: "MISSING" });
|
||||
}
|
||||
return res.json(status);
|
||||
});
|
||||
|
||||
export default router;
|
||||
131
apps/server/src/types.ts
Normal file
131
apps/server/src/types.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
export type LoopStatus =
|
||||
| "IDLE"
|
||||
| "RUNNING"
|
||||
| "WAITING_DELAY"
|
||||
| "STOPPED"
|
||||
| "ERROR"
|
||||
| "COMPLETED";
|
||||
|
||||
export type EnforcementMode = "aggressive-soft";
|
||||
|
||||
export interface User {
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
pollIntervalMs: number;
|
||||
enforceIntervalMs: number;
|
||||
defaultDelayMs: number;
|
||||
maxLoopLimit: number;
|
||||
}
|
||||
|
||||
export interface LoopJob {
|
||||
id: string;
|
||||
torrentHash: string;
|
||||
name: string;
|
||||
sizeBytes: number;
|
||||
magnet?: string;
|
||||
torrentFilePath?: string;
|
||||
allowIp: string;
|
||||
targetLoops: number;
|
||||
doneLoops: number;
|
||||
delayMs: number;
|
||||
deleteDataBetweenLoops: boolean;
|
||||
enforcementMode: EnforcementMode;
|
||||
status: LoopStatus;
|
||||
currentRun?: {
|
||||
startedAt: string;
|
||||
lastProgress: number;
|
||||
lastProgressAt?: string;
|
||||
stalledSince?: string;
|
||||
downloadedThisRunBytes: number;
|
||||
avgSpeed: number;
|
||||
};
|
||||
totals: {
|
||||
totalDownloadedBytes: number;
|
||||
totalTimeMs: number;
|
||||
};
|
||||
bans: {
|
||||
bannedIps: string[];
|
||||
lastBanAt?: string;
|
||||
};
|
||||
nextRunAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
id: string;
|
||||
name: string;
|
||||
allowIp: string;
|
||||
delayMs: number;
|
||||
targetLoops: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TimerRule {
|
||||
id: string;
|
||||
tags: string[];
|
||||
seedLimitSeconds: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TimerLog {
|
||||
id: string;
|
||||
hash: string;
|
||||
name: string;
|
||||
sizeBytes: number;
|
||||
tracker?: string;
|
||||
tags: string[];
|
||||
category?: string;
|
||||
seedingTimeSeconds: number;
|
||||
uploadedBytes: number;
|
||||
deletedAt: string;
|
||||
}
|
||||
|
||||
export interface TimerSummary {
|
||||
totalDeleted: number;
|
||||
totalSeededSeconds: number;
|
||||
totalUploadedBytes: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AuditLog {
|
||||
id: string;
|
||||
level: "INFO" | "WARN" | "ERROR";
|
||||
event:
|
||||
| "JOB_STARTED"
|
||||
| "JOB_COMPLETED_LOOP"
|
||||
| "JOB_COMPLETED_ALL"
|
||||
| "JOB_RESTARTED"
|
||||
| "PEER_BANNED"
|
||||
| "QBIT_RELOGIN"
|
||||
| "ARCHIVE_SUCCESS"
|
||||
| "ARCHIVE_FAIL";
|
||||
message: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ArchiveStatus {
|
||||
hash: string;
|
||||
status: "PENDING" | "READY" | "FAILED" | "MISSING";
|
||||
torrentFilePath?: string;
|
||||
source?: "manual" | "generated" | "exported";
|
||||
lastError?: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DbSchema {
|
||||
users: User[];
|
||||
settings: Settings;
|
||||
loopJobs: LoopJob[];
|
||||
profiles: Profile[];
|
||||
auditLogs: AuditLog[];
|
||||
archives: Record<string, ArchiveStatus>;
|
||||
timerRules?: TimerRule[];
|
||||
timerLogs?: TimerLog[];
|
||||
timerSummary?: TimerSummary;
|
||||
}
|
||||
34
apps/server/src/utils/logger.ts
Normal file
34
apps/server/src/utils/logger.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import pino from "pino";
|
||||
import { config } from "../config"
|
||||
import { AuditLog } from "../types"
|
||||
|
||||
export const logger = pino({
|
||||
level: process.env.LOG_LEVEL ?? "info",
|
||||
});
|
||||
|
||||
export const appendAuditLog = async (
|
||||
entry: Omit<AuditLog, "id" | "createdAt">
|
||||
) => {
|
||||
const logEntry: AuditLog = {
|
||||
id: randomUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
...entry,
|
||||
};
|
||||
try {
|
||||
let existing: AuditLog[] = [];
|
||||
try {
|
||||
const content = await fs.readFile(config.logsPath, "utf-8");
|
||||
existing = JSON.parse(content) as AuditLog[];
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const next = [...existing, logEntry].slice(-2000);
|
||||
await fs.writeFile(config.logsPath, JSON.stringify(next, null, 2), "utf-8");
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to append audit log");
|
||||
}
|
||||
};
|
||||
7
apps/server/src/utils/time.ts
Normal file
7
apps/server/src/utils/time.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const nowIso = () => new Date().toISOString();
|
||||
|
||||
export const secondsBetween = (startIso: string, endIso: string) => {
|
||||
const start = new Date(startIso).getTime();
|
||||
const end = new Date(endIso).getTime();
|
||||
return Math.max(0, (end - start) / 1000);
|
||||
};
|
||||
25
apps/server/src/utils/validators.ts
Normal file
25
apps/server/src/utils/validators.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const allowIpSchema = z
|
||||
.string()
|
||||
.ip({ version: "v4" })
|
||||
.or(z.string().ip({ version: "v6" }));
|
||||
|
||||
export const loopStartSchema = z.object({
|
||||
hash: z.string().min(1),
|
||||
allowIp: allowIpSchema,
|
||||
targetLoops: z.number().int().min(1).max(1000),
|
||||
delayMs: z.number().int().min(0).max(86_400_000),
|
||||
});
|
||||
|
||||
export const dryRunSchema = z.object({
|
||||
hash: z.string().min(1),
|
||||
allowIp: allowIpSchema,
|
||||
});
|
||||
|
||||
export const profileSchema = z.object({
|
||||
name: z.string().min(1).max(64),
|
||||
allowIp: allowIpSchema,
|
||||
delayMs: z.number().int().min(0).max(86_400_000),
|
||||
targetLoops: z.number().int().min(1).max(1000),
|
||||
});
|
||||
14
apps/server/tsconfig.json
Normal file
14
apps/server/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
10
apps/web/Dockerfile
Normal file
10
apps/web/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
RUN corepack enable
|
||||
COPY package.json /app/apps/web/package.json
|
||||
COPY package.json /app/package.json
|
||||
COPY pnpm-workspace.yaml /app/pnpm-workspace.yaml
|
||||
RUN pnpm install --frozen-lockfile=false
|
||||
WORKDIR /app/apps/web
|
||||
EXPOSE 5173
|
||||
CMD ["pnpm", "dev", "--host", "0.0.0.0"]
|
||||
19
apps/web/index.html
Normal file
19
apps/web/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<title>q-buffer</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
33
apps/web/package.json
Normal file
33
apps/web/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "q-buffer-web",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^6.6.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.6.0",
|
||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||
"axios": "^1.7.7",
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"socket.io-client": "^4.7.5",
|
||||
"zustand": "^4.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.39",
|
||||
"tailwindcss": "^3.4.6",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.3.3"
|
||||
}
|
||||
}
|
||||
6
apps/web/postcss.config.cjs
Normal file
6
apps/web/postcss.config.cjs
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
33
apps/web/src/App.tsx
Normal file
33
apps/web/src/App.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
import { DashboardPage } from "./pages/DashboardPage";
|
||||
import { TimerPage } from "./pages/TimerPage";
|
||||
import { AppLayout } from "./components/layout/AppLayout";
|
||||
import { useAuthStore } from "./store/useAuthStore";
|
||||
|
||||
export const App = () => {
|
||||
const username = useAuthStore((s) => s.username);
|
||||
const check = useAuthStore((s) => s.check);
|
||||
|
||||
useEffect(() => {
|
||||
check();
|
||||
}, [check]);
|
||||
|
||||
if (!username) {
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AppLayout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/buffer" replace />} />
|
||||
<Route path="/buffer" element={<DashboardPage />} />
|
||||
<Route path="/timer" element={<TimerPage />} />
|
||||
<Route path="*" element={<Navigate to="/buffer" replace />} />
|
||||
</Routes>
|
||||
</AppLayout>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
8
apps/web/src/api/client.ts
Normal file
8
apps/web/src/api/client.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import axios from "axios";
|
||||
|
||||
const baseURL = import.meta.env.VITE_API_BASE || "";
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: baseURL || undefined,
|
||||
withCredentials: true,
|
||||
});
|
||||
137
apps/web/src/components/layout/AppLayout.tsx
Normal file
137
apps/web/src/components/layout/AppLayout.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { Shell } from "./Shell";
|
||||
import { Badge } from "../ui/Badge";
|
||||
import { Button } from "../ui/Button";
|
||||
import { useAuthStore } from "../../store/useAuthStore";
|
||||
import { useAppStore } from "../../store/useAppStore";
|
||||
import { connectSocket } from "../../socket/socket";
|
||||
import { api } from "../../api/client";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faMoon, faSun } from "@fortawesome/free-solid-svg-icons";
|
||||
import { AlertToastStack } from "../ui/AlertToastStack";
|
||||
|
||||
export const AppLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const qbit = useAppStore((s) => s.qbit);
|
||||
const setSnapshot = useAppStore((s) => s.setSnapshot);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [theme, setTheme] = useState<"light" | "dark">("light");
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
const applyTheme = (next: "light" | "dark") => {
|
||||
document.documentElement.classList.toggle("dark", next === "dark");
|
||||
localStorage.setItem("theme", next);
|
||||
setTheme(next);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem("theme");
|
||||
if (stored === "light" || stored === "dark") {
|
||||
applyTheme(stored);
|
||||
} else if (window.matchMedia?.("(prefers-color-scheme: dark)").matches) {
|
||||
applyTheme("dark");
|
||||
}
|
||||
const socket = connectSocket();
|
||||
socket.on("connect", () => setConnected(true));
|
||||
socket.on("disconnect", () => setConnected(false));
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const response = await api.get("/api/status");
|
||||
if (active) {
|
||||
setSnapshot(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (active) {
|
||||
// Ignore transient network errors; socket will update when available.
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchStatus();
|
||||
const interval = setInterval(fetchStatus, connected ? 15000 : 5000);
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [connected, setSnapshot]);
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<header className="rounded-xl border border-slate-200 bg-white/80 px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-3 md:items-center">
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-slate-900">q-buffer</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
qBittorrent {qbit.version ?? "unknown"}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="inline-flex items-center justify-center rounded-md border border-slate-300 px-3 py-2 text-xs font-semibold text-slate-700 md:hidden"
|
||||
onClick={() => setMenuOpen((open) => !open)}
|
||||
type="button"
|
||||
>
|
||||
{menuOpen ? "Close" : "Menu"}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={`mt-3 flex flex-col gap-3 md:mt-0 md:flex md:flex-row md:items-center md:justify-between ${
|
||||
menuOpen ? "flex" : "hidden md:flex"
|
||||
}`}
|
||||
>
|
||||
<nav className="flex flex-wrap items-center gap-2 rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-600 md:justify-start">
|
||||
<NavLink
|
||||
to="/buffer"
|
||||
className={({ isActive }) =>
|
||||
`rounded-full px-3 py-1 ${
|
||||
isActive ? "bg-slate-900 text-white" : "hover:bg-white"
|
||||
}`
|
||||
}
|
||||
>
|
||||
Buffer
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/timer"
|
||||
className={({ isActive }) =>
|
||||
`rounded-full px-3 py-1 ${
|
||||
isActive ? "bg-slate-900 text-white" : "hover:bg-white"
|
||||
}`
|
||||
}
|
||||
>
|
||||
Timer
|
||||
</NavLink>
|
||||
</nav>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={qbit.ok ? "success" : "danger"}>
|
||||
{qbit.ok ? "Qbit OK" : "Qbit Down"}
|
||||
</Badge>
|
||||
<Badge variant={connected ? "success" : "warn"}>
|
||||
{connected ? "Live" : "Offline"}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => applyTheme(theme === "dark" ? "light" : "dark")}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={theme === "dark" ? faSun : faMoon}
|
||||
className="mr-2"
|
||||
/>
|
||||
{theme === "dark" ? "Light" : "Dark"}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={logout}>
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<AlertToastStack />
|
||||
{children}
|
||||
</Shell>
|
||||
);
|
||||
};
|
||||
9
apps/web/src/components/layout/Shell.tsx
Normal file
9
apps/web/src/components/layout/Shell.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
|
||||
export const Shell = ({ children }: { children: React.ReactNode }) => (
|
||||
<div className="dashboard-bg min-h-screen">
|
||||
<div className="mx-auto flex min-h-screen w-full max-w-6xl flex-col gap-6 px-6 py-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
111
apps/web/src/components/loop/AdvancedUploadCard.tsx
Normal file
111
apps/web/src/components/loop/AdvancedUploadCard.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/Card";
|
||||
import { Button } from "../ui/Button";
|
||||
import { api } from "../../api/client";
|
||||
import { useAppStore } from "../../store/useAppStore";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCircleInfo, faTriangleExclamation, faUpload } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useUiStore } from "../../store/useUiStore";
|
||||
|
||||
export const AdvancedUploadCard = () => {
|
||||
const selectedHash = useAppStore((s) => s.selectedHash);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const pushAlert = useUiStore((s) => s.pushAlert);
|
||||
|
||||
const upload = async () => {
|
||||
if (!selectedHash) {
|
||||
pushAlert({
|
||||
title: "Torrent seçilmedi",
|
||||
description: "Önce bir torrent seçmelisiniz.",
|
||||
variant: "warn",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!file) {
|
||||
pushAlert({
|
||||
title: "Dosya seçilmedi",
|
||||
description: "Lütfen bir .torrent dosyası seçin.",
|
||||
variant: "warn",
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("hash", selectedHash);
|
||||
const response = await api.post("/api/torrent/archive/upload", form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
if (response.data?.added) {
|
||||
pushAlert({
|
||||
title: "Yükleme başarılı",
|
||||
description: "Torrent qBittorrent'a eklendi.",
|
||||
variant: "success",
|
||||
});
|
||||
} else {
|
||||
pushAlert({
|
||||
title: "Yükleme yapıldı",
|
||||
description: "Torrent arşive alındı, qBittorrent'a eklenemedi.",
|
||||
variant: "warn",
|
||||
});
|
||||
}
|
||||
if (selectedHash) {
|
||||
const statusResponse = await api.get(
|
||||
`/api/torrent/archive/status/${selectedHash}`
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("archive-status", {
|
||||
detail: { hash: selectedHash, status: statusResponse.data?.status },
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
const apiError = error?.response?.data?.error;
|
||||
pushAlert({
|
||||
title: "Yükleme başarısız",
|
||||
description: apiError || "Sunucu loglarını kontrol edin.",
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faCircleInfo} className="text-slate-400" />
|
||||
Advanced
|
||||
</CardTitle>
|
||||
<Button variant="ghost" onClick={() => setOpen(!open)}>
|
||||
{open ? "Hide" : "Show"}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
{open && (
|
||||
<CardContent>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center gap-2 text-slate-600">
|
||||
<FontAwesomeIcon icon={faCircleInfo} className="text-slate-400" />
|
||||
Upload a .torrent file if magnet metadata fetch fails.
|
||||
</div>
|
||||
{!selectedHash && (
|
||||
<div className="flex items-center gap-2 text-amber-700">
|
||||
<FontAwesomeIcon icon={faTriangleExclamation} />
|
||||
Önce bir torrent seçmelisiniz.
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept=".torrent"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<Button onClick={upload} disabled={!file || !selectedHash}>
|
||||
<FontAwesomeIcon icon={faUpload} className="mr-2" />
|
||||
Upload Torrent
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
65
apps/web/src/components/loop/LogsPanel.tsx
Normal file
65
apps/web/src/components/loop/LogsPanel.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React from "react";
|
||||
import { useAppStore } from "../../store/useAppStore";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/Card";
|
||||
import { Badge } from "../ui/Badge";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faCircleExclamation,
|
||||
faCircleInfo,
|
||||
faList,
|
||||
faTriangleExclamation,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
export const LogsPanel = () => {
|
||||
const logs = useAppStore((s) => s.logs);
|
||||
const selectedHash = useAppStore((s) => s.selectedHash);
|
||||
const jobs = useAppStore((s) => s.jobs);
|
||||
const job = jobs.find((j) => j.torrentHash === selectedHash);
|
||||
const filtered = job ? logs.filter((log) => log.jobId === job.id) : logs;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faList} className="text-slate-400" />
|
||||
Logs
|
||||
</CardTitle>
|
||||
<Badge variant="default">Live</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-h-56 space-y-2 overflow-auto text-xs">
|
||||
{[...filtered].reverse().map((log, idx) => (
|
||||
<div
|
||||
key={`${log.createdAt}-${idx}`}
|
||||
className="rounded-md border border-slate-200 bg-white px-2 py-1"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-2 font-semibold">
|
||||
<FontAwesomeIcon
|
||||
icon={
|
||||
log.level === "ERROR"
|
||||
? faTriangleExclamation
|
||||
: log.level === "WARN"
|
||||
? faCircleExclamation
|
||||
: faCircleInfo
|
||||
}
|
||||
className={
|
||||
log.level === "ERROR"
|
||||
? "text-rose-500"
|
||||
: log.level === "WARN"
|
||||
? "text-amber-500"
|
||||
: "text-slate-400"
|
||||
}
|
||||
/>
|
||||
{log.level}
|
||||
</span>
|
||||
<span className="text-slate-400">{log.createdAt}</span>
|
||||
</div>
|
||||
<div>{log.message}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
111
apps/web/src/components/loop/LoopSetupCard.tsx
Normal file
111
apps/web/src/components/loop/LoopSetupCard.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { useState } from "react";
|
||||
import { useAppStore } from "../../store/useAppStore";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/Card";
|
||||
import { Input } from "../ui/Input";
|
||||
import { Button } from "../ui/Button";
|
||||
import { api } from "../../api/client";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCircleInfo, faClock, faList, faLock } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
export const LoopSetupCard = () => {
|
||||
const selectedHash = useAppStore((s) => s.selectedHash);
|
||||
const jobs = useAppStore((s) => s.jobs);
|
||||
const loopForm = useAppStore((s) => s.loopForm);
|
||||
const setLoopForm = useAppStore((s) => s.setLoopForm);
|
||||
const [dryRun, setDryRun] = useState<string | null>(null);
|
||||
|
||||
const job = jobs.find((j) => j.torrentHash === selectedHash);
|
||||
|
||||
const startLoop = async () => {
|
||||
if (!selectedHash) {
|
||||
return;
|
||||
}
|
||||
await api.post("/api/loop/start", {
|
||||
hash: selectedHash,
|
||||
allowIp: loopForm.allowIp,
|
||||
targetLoops: loopForm.targetLoops,
|
||||
delayMs: loopForm.delayMs,
|
||||
});
|
||||
};
|
||||
|
||||
const stopLoop = async () => {
|
||||
if (!selectedHash) {
|
||||
return;
|
||||
}
|
||||
await api.post("/api/loop/stop-by-hash", { hash: selectedHash });
|
||||
};
|
||||
|
||||
const runDry = async () => {
|
||||
if (!selectedHash) {
|
||||
return;
|
||||
}
|
||||
const response = await api.post("/api/loop/dry-run", {
|
||||
hash: selectedHash,
|
||||
allowIp: loopForm.allowIp || "127.0.0.1",
|
||||
});
|
||||
setDryRun(JSON.stringify(response.data, null, 2));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faCircleInfo} className="text-slate-400" />
|
||||
Loop Setup
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 text-sm">
|
||||
<label className="space-y-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faLock} className="text-slate-400" />
|
||||
Allow IP
|
||||
</span>
|
||||
<Input
|
||||
value={loopForm.allowIp}
|
||||
onChange={(e) => setLoopForm({ allowIp: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faList} className="text-slate-400" />
|
||||
Loops
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
value={loopForm.targetLoops}
|
||||
onChange={(e) => setLoopForm({ targetLoops: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faClock} className="text-slate-400" />
|
||||
Delay (ms)
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
value={loopForm.delayMs}
|
||||
onChange={(e) => setLoopForm({ delayMs: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={startLoop} disabled={!selectedHash || !loopForm.allowIp}>
|
||||
Start
|
||||
</Button>
|
||||
<Button variant="outline" onClick={stopLoop} disabled={!selectedHash}>
|
||||
Stop
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={runDry} disabled={!selectedHash}>
|
||||
Dry Run
|
||||
</Button>
|
||||
</div>
|
||||
{dryRun && (
|
||||
<pre className="max-h-40 overflow-auto rounded-md bg-slate-900 p-3 text-xs text-slate-100">
|
||||
{dryRun}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
92
apps/web/src/components/loop/LoopStatsCard.tsx
Normal file
92
apps/web/src/components/loop/LoopStatsCard.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React from "react";
|
||||
import { useAppStore } from "../../store/useAppStore";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/Card";
|
||||
import { Badge } from "../ui/Badge";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faBan,
|
||||
faCircleInfo,
|
||||
faClock,
|
||||
faDownload,
|
||||
faList,
|
||||
faTriangleExclamation,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
export const LoopStatsCard = () => {
|
||||
const selectedHash = useAppStore((s) => s.selectedHash);
|
||||
const jobs = useAppStore((s) => s.jobs);
|
||||
const job = jobs.find((j) => j.torrentHash === selectedHash);
|
||||
|
||||
const formatTotal = (bytes: number) => {
|
||||
const gb = bytes / (1024 ** 3);
|
||||
if (gb >= 1024) {
|
||||
return `${(gb / 1024).toFixed(2)} TB`;
|
||||
}
|
||||
return `${gb.toFixed(2)} GB`;
|
||||
};
|
||||
|
||||
if (!job) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Loop Stats</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-slate-500">No active job for selection.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faCircleInfo} className="text-slate-400" />
|
||||
Loop Stats
|
||||
</CardTitle>
|
||||
<Badge variant={job.status === "RUNNING" ? "success" : "warn"}>
|
||||
{job.status}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-2 text-sm text-slate-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faList} className="text-slate-400" />
|
||||
<span>Loops: {job.doneLoops} / {job.targetLoops}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faDownload} className="text-slate-400" />
|
||||
<span>
|
||||
Total Download:{" "}
|
||||
{formatTotal(
|
||||
job.totals?.totalDownloadedBytes ??
|
||||
job.doneLoops * job.sizeBytes
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faClock} className="text-slate-400" />
|
||||
<span>Delay: {job.delayMs} ms</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faBan} className="text-slate-400" />
|
||||
<span>Banned peers: {job.bans?.bannedIps?.length ?? 0}</span>
|
||||
</div>
|
||||
{job.nextRunAt && (
|
||||
<div className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faClock} className="text-slate-400" />
|
||||
<span>Next run: {job.nextRunAt}</span>
|
||||
</div>
|
||||
)}
|
||||
{job.lastError && (
|
||||
<div className="flex items-center gap-2 text-rose-600">
|
||||
<FontAwesomeIcon icon={faTriangleExclamation} />
|
||||
<span>{job.lastError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
185
apps/web/src/components/loop/ProfilesCard.tsx
Normal file
185
apps/web/src/components/loop/ProfilesCard.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/Card";
|
||||
import { Input } from "../ui/Input";
|
||||
import { Button } from "../ui/Button";
|
||||
import { api } from "../../api/client";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faList, faLock, faUser } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "../ui/AlertDialog";
|
||||
import { useUiStore } from "../../store/useUiStore";
|
||||
|
||||
interface Profile {
|
||||
id: string;
|
||||
name: string;
|
||||
allowIp: string;
|
||||
delayMs: number;
|
||||
targetLoops: number;
|
||||
}
|
||||
|
||||
export const ProfilesCard = ({ onApply }: { onApply?: (profile: Profile) => void }) => {
|
||||
const [profiles, setProfiles] = useState<Profile[]>([]);
|
||||
const [name, setName] = useState("");
|
||||
const [allowIp, setAllowIp] = useState("");
|
||||
const [delayMs, setDelayMs] = useState(3000);
|
||||
const [targetLoops, setTargetLoops] = useState(3);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const pushAlert = useUiStore((s) => s.pushAlert);
|
||||
|
||||
const loadProfiles = async () => {
|
||||
const response = await api.get("/api/profiles");
|
||||
setProfiles(response.data);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadProfiles();
|
||||
}, []);
|
||||
|
||||
const saveProfile = async () => {
|
||||
const payload = {
|
||||
name,
|
||||
allowIp,
|
||||
delayMs,
|
||||
targetLoops,
|
||||
};
|
||||
try {
|
||||
if (editingId) {
|
||||
const response = await api.put(`/api/profiles/${editingId}`, payload);
|
||||
setProfiles((prev) =>
|
||||
prev.map((profile) => (profile.id === editingId ? response.data : profile))
|
||||
);
|
||||
pushAlert({
|
||||
title: "Profil güncellendi",
|
||||
description: "Profil bilgileri kaydedildi.",
|
||||
variant: "success",
|
||||
});
|
||||
setEditingId(null);
|
||||
} else {
|
||||
const response = await api.post("/api/profiles", payload);
|
||||
setProfiles((prev) => [...prev, response.data]);
|
||||
pushAlert({
|
||||
title: "Profil kaydedildi",
|
||||
description: "Yeni profil eklendi.",
|
||||
variant: "success",
|
||||
});
|
||||
}
|
||||
setName("");
|
||||
setAllowIp("");
|
||||
setDelayMs(3000);
|
||||
setTargetLoops(3);
|
||||
} catch (error) {
|
||||
pushAlert({
|
||||
title: "Profil kaydedilemedi",
|
||||
description: "Lütfen bilgileri kontrol edip tekrar deneyin.",
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (profile: Profile) => {
|
||||
setEditingId(profile.id);
|
||||
setName(profile.name);
|
||||
setAllowIp(profile.allowIp);
|
||||
setDelayMs(profile.delayMs);
|
||||
setTargetLoops(profile.targetLoops);
|
||||
};
|
||||
|
||||
const removeProfile = async (profileId: string) => {
|
||||
try {
|
||||
await api.delete(`/api/profiles/${profileId}`);
|
||||
setProfiles((prev) => prev.filter((profile) => profile.id !== profileId));
|
||||
pushAlert({
|
||||
title: "Profil silindi",
|
||||
description: "Profil listeden kaldırıldı.",
|
||||
variant: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
pushAlert({
|
||||
title: "Silme başarısız",
|
||||
description: "Profil silinemedi.",
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faUser} className="text-slate-400" />
|
||||
Profiles
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 text-sm">
|
||||
<div className="grid gap-2">
|
||||
<Input placeholder="Preset name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<Input placeholder="Allow IP" value={allowIp} onChange={(e) => setAllowIp(e.target.value)} />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input type="number" value={delayMs} onChange={(e) => setDelayMs(Number(e.target.value))} />
|
||||
<Input type="number" value={targetLoops} onChange={(e) => setTargetLoops(Number(e.target.value))} />
|
||||
</div>
|
||||
<Button onClick={saveProfile}>
|
||||
{editingId ? "Update Profile" : "Save Profile"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{profiles.map((profile) => (
|
||||
<div key={profile.id} className="flex items-center justify-between rounded-md border border-slate-200 bg-white px-3 py-2">
|
||||
<div>
|
||||
<div className="font-semibold">{profile.name}</div>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<FontAwesomeIcon icon={faLock} className="text-slate-400" />
|
||||
{profile.allowIp}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<FontAwesomeIcon icon={faList} className="text-slate-400" />
|
||||
{profile.targetLoops} loops
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => startEdit(profile)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => onApply?.(profile)}>
|
||||
Apply
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost">Delete</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Profil silinsin mi?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Bu işlem geri alınamaz.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>İptal</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => removeProfile(profile.id)}>
|
||||
Sil
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
111
apps/web/src/components/torrents/TorrentDetailsCard.tsx
Normal file
111
apps/web/src/components/torrents/TorrentDetailsCard.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useAppStore } from "../../store/useAppStore";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/Card";
|
||||
import { Badge } from "../ui/Badge";
|
||||
import { api } from "../../api/client";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faCircleInfo,
|
||||
faDatabase,
|
||||
faHashtag,
|
||||
faLink,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
export const TorrentDetailsCard = () => {
|
||||
const selectedHash = useAppStore((s) => s.selectedHash);
|
||||
const torrents = useAppStore((s) => s.torrents);
|
||||
const [archiveStatus, setArchiveStatus] = useState<string>("MISSING");
|
||||
|
||||
const torrent = torrents.find((t) => t.hash === selectedHash);
|
||||
|
||||
const refreshArchiveStatus = async (hash: string) => {
|
||||
try {
|
||||
const response = await api.get(`/api/torrent/archive/status/${hash}`);
|
||||
setArchiveStatus(response.data.status);
|
||||
} catch (error) {
|
||||
setArchiveStatus("MISSING");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedHash) {
|
||||
return;
|
||||
}
|
||||
api.post("/api/torrent/select", { hash: selectedHash });
|
||||
refreshArchiveStatus(selectedHash);
|
||||
}, [selectedHash]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ hash: string; status: string }>).detail;
|
||||
if (detail?.hash === selectedHash && detail.status) {
|
||||
setArchiveStatus(detail.status);
|
||||
}
|
||||
};
|
||||
window.addEventListener("archive-status", handler as EventListener);
|
||||
return () => window.removeEventListener("archive-status", handler as EventListener);
|
||||
}, [selectedHash]);
|
||||
|
||||
if (!torrent) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Selected Torrent</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-slate-500">Select a torrent to inspect.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faCircleInfo} className="text-slate-400" />
|
||||
Selected Torrent
|
||||
</CardTitle>
|
||||
<Badge
|
||||
variant={archiveStatus === "READY" ? "success" : "warn"}
|
||||
>
|
||||
Archive: {archiveStatus}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm text-slate-700">
|
||||
<div className="truncate font-semibold text-slate-900" title={torrent.name}>
|
||||
{torrent.name}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faHashtag} className="text-slate-400" />
|
||||
<span>Hash: {torrent.hash}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faDatabase} className="text-slate-400" />
|
||||
<span>
|
||||
Size: {(torrent.size / (1024 * 1024 * 1024)).toFixed(2)} GB
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 truncate" title={torrent.tracker || "-"}>
|
||||
<FontAwesomeIcon icon={faLink} className="text-slate-400" />
|
||||
<span className="truncate">
|
||||
Tracker:{" "}
|
||||
{(() => {
|
||||
if (!torrent.tracker) {
|
||||
return "-";
|
||||
}
|
||||
try {
|
||||
const url = new URL(torrent.tracker);
|
||||
return url.hostname;
|
||||
} catch {
|
||||
return torrent.tracker;
|
||||
}
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
219
apps/web/src/components/torrents/TorrentTable.tsx
Normal file
219
apps/web/src/components/torrents/TorrentTable.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useAppStore } from "../../store/useAppStore";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/Card";
|
||||
import { Input } from "../ui/Input";
|
||||
import { api } from "../../api/client";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "../ui/AlertDialog";
|
||||
import { useUiStore } from "../../store/useUiStore";
|
||||
|
||||
const formatSpeed = (bytesPerSec: number) => {
|
||||
const kb = bytesPerSec / 1024;
|
||||
if (kb >= 1024) {
|
||||
return `${(kb / 1024).toFixed(1)} MB/s`;
|
||||
}
|
||||
return `${Math.round(kb)} kB/s`;
|
||||
};
|
||||
|
||||
export const TorrentTable = () => {
|
||||
const torrents = useAppStore((s) => s.torrents);
|
||||
const selected = useAppStore((s) => s.selectedHash);
|
||||
const selectHash = useAppStore((s) => s.selectHash);
|
||||
const pushAlert = useUiStore((s) => s.pushAlert);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return torrents.filter((torrent) =>
|
||||
torrent.name.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
}, [torrents, query]);
|
||||
|
||||
const deleteTorrent = async (hash: string) => {
|
||||
try {
|
||||
await api.delete(`/api/qbit/torrent/${hash}`);
|
||||
pushAlert({
|
||||
title: "Torrent silindi",
|
||||
description: "Torrent ve dosyalar diskten kaldırıldı.",
|
||||
variant: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
pushAlert({
|
||||
title: "Silme başarısız",
|
||||
description: "Torrent silinemedi. Sunucu loglarını kontrol edin.",
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const uploadTorrent = async (hash: string, file: File) => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("hash", hash);
|
||||
try {
|
||||
const response = await api.post("/api/torrent/archive/upload", form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
if (response.data?.added) {
|
||||
pushAlert({
|
||||
title: "Yükleme başarılı",
|
||||
description: "Torrent qBittorrent'a eklendi.",
|
||||
variant: "success",
|
||||
});
|
||||
} else {
|
||||
pushAlert({
|
||||
title: "Yükleme yapıldı",
|
||||
description: "Torrent arşive alındı, qBittorrent'a eklenemedi.",
|
||||
variant: "warn",
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
const apiError = error?.response?.data?.error;
|
||||
pushAlert({
|
||||
title: "Yükleme başarısız",
|
||||
description: apiError || "Sunucu loglarını kontrol edin.",
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Torrents</CardTitle>
|
||||
<Input
|
||||
placeholder="Search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
className="w-40"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-h-[360px] overflow-auto space-y-2">
|
||||
{filtered.map((torrent) => (
|
||||
<div
|
||||
key={torrent.hash}
|
||||
className={`flex items-start justify-between rounded-lg border px-3 py-2 text-left text-sm transition ${
|
||||
selected === torrent.hash
|
||||
? "border-ink bg-slate-900 text-white"
|
||||
: "border-slate-200 bg-white hover:border-slate-300"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="flex-1 cursor-pointer"
|
||||
onClick={() => selectHash(torrent.hash)}
|
||||
>
|
||||
<div className="truncate font-semibold" title={torrent.name}>
|
||||
{torrent.name}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-3 text-xs">
|
||||
<div className="h-2 w-28 rounded-full bg-slate-200">
|
||||
<div
|
||||
className="h-2 rounded-full bg-mint"
|
||||
style={{ width: `${Math.round(torrent.progress * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span>{Math.round(torrent.progress * 100)}%</span>
|
||||
<span>{formatSpeed(torrent.dlspeed)}</span>
|
||||
<span className="uppercase text-slate-400">
|
||||
{torrent.state}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-3 flex items-center gap-2">
|
||||
<label
|
||||
className={`cursor-pointer rounded-md p-2 transition ${
|
||||
selected === torrent.hash
|
||||
? "text-white/80 hover:text-white"
|
||||
: "text-slate-500 hover:text-slate-900"
|
||||
}`}
|
||||
title="Torrent yükle"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept=".torrent"
|
||||
className="hidden"
|
||||
onChange={async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
await uploadTorrent(torrent.hash, file);
|
||||
}
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-4 w-4"
|
||||
>
|
||||
<path d="M12 3v12" />
|
||||
<path d="M8 7l4-4 4 4" />
|
||||
<path d="M4 15v4h16v-4" />
|
||||
</svg>
|
||||
</label>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<button
|
||||
className={`rounded-md p-2 transition ${
|
||||
selected === torrent.hash
|
||||
? "text-white/80 hover:text-white"
|
||||
: "text-slate-500 hover:text-slate-900"
|
||||
}`}
|
||||
title="Sil"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-4 w-4"
|
||||
>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4h8v2" />
|
||||
<path d="M19 6l-1 14H6L5 6" />
|
||||
<path d="M10 11v6" />
|
||||
<path d="M14 11v6" />
|
||||
</svg>
|
||||
</button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent onClick={(event) => event.stopPropagation()}>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Torrent silinsin mi?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Bu işlem torrent ve indirilmiş dosyaları kalıcı olarak siler.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>İptal</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => deleteTorrent(torrent.hash)}>
|
||||
Sil
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
36
apps/web/src/components/ui/Alert.tsx
Normal file
36
apps/web/src/components/ui/Alert.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export const Alert = ({
|
||||
variant = "default",
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & {
|
||||
variant?: "default" | "success" | "warn" | "error";
|
||||
}) => {
|
||||
const variants: Record<string, string> = {
|
||||
default: "border-slate-200 bg-white text-slate-700",
|
||||
success: "border-emerald-200 bg-emerald-50 text-emerald-800",
|
||||
warn: "border-amber-200 bg-amber-50 text-amber-800",
|
||||
error: "border-rose-200 bg-rose-50 text-rose-800",
|
||||
};
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={clsx(
|
||||
"w-full rounded-xl border px-4 py-3 shadow-lg backdrop-blur",
|
||||
variants[variant],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const AlertTitle = ({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h5 className={clsx("text-sm font-semibold", className)} {...props} />
|
||||
);
|
||||
|
||||
export const AlertDescription = ({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) => (
|
||||
<p className={clsx("mt-1 text-xs", className)} {...props} />
|
||||
);
|
||||
69
apps/web/src/components/ui/AlertDialog.tsx
Normal file
69
apps/web/src/components/ui/AlertDialog.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React from "react";
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||
import clsx from "clsx";
|
||||
|
||||
export const AlertDialog = AlertDialogPrimitive.Root;
|
||||
export const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
export const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||
|
||||
export const AlertDialogOverlay = ({ className, ...props }: AlertDialogPrimitive.AlertDialogOverlayProps) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={clsx("fixed inset-0 z-50 bg-black/40 backdrop-blur-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export const AlertDialogContent = ({
|
||||
className,
|
||||
...props
|
||||
}: AlertDialogPrimitive.AlertDialogContentProps) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
className={clsx(
|
||||
"fixed left-1/2 top-1/2 z-50 w-[90vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-slate-200 bg-white p-6 shadow-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
);
|
||||
|
||||
export const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={clsx("space-y-2", className)} {...props} />
|
||||
);
|
||||
|
||||
export const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={clsx("mt-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props} />
|
||||
);
|
||||
|
||||
export const AlertDialogTitle = ({ className, ...props }: AlertDialogPrimitive.AlertDialogTitleProps) => (
|
||||
<AlertDialogPrimitive.Title className={clsx("text-lg font-semibold text-slate-900", className)} {...props} />
|
||||
);
|
||||
|
||||
export const AlertDialogDescription = ({
|
||||
className,
|
||||
...props
|
||||
}: AlertDialogPrimitive.AlertDialogDescriptionProps) => (
|
||||
<AlertDialogPrimitive.Description className={clsx("text-sm text-slate-600", className)} {...props} />
|
||||
);
|
||||
|
||||
export const AlertDialogCancel = ({ className, ...props }: AlertDialogPrimitive.AlertDialogCancelProps) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={clsx(
|
||||
"inline-flex items-center justify-center rounded-md border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export const AlertDialogAction = ({ className, ...props }: AlertDialogPrimitive.AlertDialogActionProps) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={clsx(
|
||||
"inline-flex items-center justify-center rounded-md bg-rose-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-rose-700",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
24
apps/web/src/components/ui/AlertToastStack.tsx
Normal file
24
apps/web/src/components/ui/AlertToastStack.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "./Alert";
|
||||
import { useUiStore } from "../../store/useUiStore";
|
||||
|
||||
export const AlertToastStack = () => {
|
||||
const alerts = useUiStore((s) => s.alerts);
|
||||
|
||||
if (alerts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="toast-stack">
|
||||
{alerts.map((alert) => (
|
||||
<Alert key={alert.id} variant={alert.variant}>
|
||||
<AlertTitle>{alert.title}</AlertTitle>
|
||||
{alert.description ? (
|
||||
<AlertDescription>{alert.description}</AlertDescription>
|
||||
) : null}
|
||||
</Alert>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
27
apps/web/src/components/ui/Badge.tsx
Normal file
27
apps/web/src/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export const Badge = ({
|
||||
variant = "default",
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement> & {
|
||||
variant?: "default" | "success" | "warn" | "danger";
|
||||
}) => {
|
||||
const variants: Record<string, string> = {
|
||||
default: "bg-slate-100 text-slate-700",
|
||||
success: "bg-emerald-100 text-emerald-700",
|
||||
warn: "bg-amber-100 text-amber-700",
|
||||
danger: "bg-rose-100 text-rose-700",
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
"inline-flex items-center rounded-full px-2 py-1 text-xs font-semibold",
|
||||
variants[variant],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
21
apps/web/src/components/ui/Button.tsx
Normal file
21
apps/web/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export const Button = ({
|
||||
variant = "default",
|
||||
className,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: "default" | "outline" | "ghost";
|
||||
}) => {
|
||||
const base =
|
||||
"inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-semibold transition";
|
||||
const variants: Record<string, string> = {
|
||||
default: "bg-ink text-white hover:bg-slate-800",
|
||||
outline: "border border-slate-300 text-slate-700 hover:bg-slate-100",
|
||||
ghost: "text-slate-600 hover:bg-slate-100",
|
||||
};
|
||||
return (
|
||||
<button className={clsx(base, variants[variant], className)} {...props} />
|
||||
);
|
||||
};
|
||||
24
apps/web/src/components/ui/Card.tsx
Normal file
24
apps/web/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export const Card = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={clsx(
|
||||
"rounded-xl border border-slate-200 bg-white/80 p-4 shadow-sm backdrop-blur",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export const CardHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={clsx("mb-3 flex items-center justify-between", className)} {...props} />
|
||||
);
|
||||
|
||||
export const CardTitle = ({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h3 className={clsx("text-lg font-semibold text-slate-900", className)} {...props} />
|
||||
);
|
||||
|
||||
export const CardContent = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={clsx("space-y-3", className)} {...props} />
|
||||
);
|
||||
12
apps/web/src/components/ui/Input.tsx
Normal file
12
apps/web/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export const Input = ({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) => (
|
||||
<input
|
||||
className={clsx(
|
||||
"w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 focus:border-ink focus:outline-none focus:ring-2 focus:ring-slate-200",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
193
apps/web/src/index.css
Normal file
193
apps/web/src/index.css
Normal file
@@ -0,0 +1,193 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color: #0f172a;
|
||||
background: #f1f5f9;
|
||||
font-family: "Space Grotesk", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
:root.dark {
|
||||
color: #e2e8f0;
|
||||
background: #0b1220;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
:root,
|
||||
html,
|
||||
body {
|
||||
text-size-adjust: 100%;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
overflow-y: scroll;
|
||||
scrollbar-gutter: stable;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dashboard-bg {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
:root.dark .dashboard-bg {
|
||||
background: #0b1220;
|
||||
}
|
||||
|
||||
:root.dark .border-slate-200 {
|
||||
border-color: #1f2937;
|
||||
}
|
||||
|
||||
:root.dark .border-slate-300 {
|
||||
border-color: #334155;
|
||||
}
|
||||
|
||||
:root.dark .bg-white\/80 {
|
||||
background-color: rgba(15, 23, 42, 0.8);
|
||||
}
|
||||
|
||||
:root.dark .bg-white {
|
||||
background-color: #0f172a;
|
||||
}
|
||||
|
||||
:root.dark .bg-slate-50 {
|
||||
background-color: #0b1220;
|
||||
}
|
||||
|
||||
:root.dark .bg-slate-100 {
|
||||
background-color: #111827;
|
||||
}
|
||||
|
||||
:root.dark .bg-slate-900 {
|
||||
background-color: #1f2937;
|
||||
}
|
||||
|
||||
:root.dark .bg-emerald-50 {
|
||||
background-color: #0f2a23;
|
||||
}
|
||||
|
||||
:root.dark .border-emerald-200 {
|
||||
border-color: #14532d;
|
||||
}
|
||||
|
||||
:root.dark .bg-amber-50 {
|
||||
background-color: #2a1f0f;
|
||||
}
|
||||
|
||||
:root.dark .border-amber-200 {
|
||||
border-color: #7c5e10;
|
||||
}
|
||||
|
||||
:root.dark .bg-rose-50 {
|
||||
background-color: #2a1115;
|
||||
}
|
||||
|
||||
:root.dark .border-rose-200 {
|
||||
border-color: #7f1d1d;
|
||||
}
|
||||
|
||||
:root.dark .text-emerald-800 {
|
||||
color: #6ee7b7;
|
||||
}
|
||||
|
||||
:root.dark .text-amber-800 {
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
:root.dark .text-rose-800 {
|
||||
color: #fda4af;
|
||||
}
|
||||
|
||||
:root.dark .text-slate-900 {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
:root.dark .text-slate-700 {
|
||||
color: #cbd5f5;
|
||||
}
|
||||
|
||||
:root.dark .text-slate-600,
|
||||
:root.dark .text-slate-500 {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
:root.dark .text-slate-400 {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
:root.dark .text-slate-200,
|
||||
:root.dark .text-slate-100,
|
||||
:root.dark .text-white {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.toast-stack {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: min(360px, 92vw);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.toast-stack {
|
||||
left: 50%;
|
||||
right: auto;
|
||||
bottom: 16px;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
:root {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.max-w-6xl {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.px-6 {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.py-6 {
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.text-xs {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.text-lg {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
button,
|
||||
textarea {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
10
apps/web/src/main.tsx
Normal file
10
apps/web/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
40
apps/web/src/pages/DashboardPage.tsx
Normal file
40
apps/web/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from "react";
|
||||
import { TorrentTable } from "../components/torrents/TorrentTable";
|
||||
import { TorrentDetailsCard } from "../components/torrents/TorrentDetailsCard";
|
||||
import { LoopSetupCard } from "../components/loop/LoopSetupCard";
|
||||
import { LoopStatsCard } from "../components/loop/LoopStatsCard";
|
||||
import { LogsPanel } from "../components/loop/LogsPanel";
|
||||
import { ProfilesCard } from "../components/loop/ProfilesCard";
|
||||
import { useAppStore } from "../store/useAppStore";
|
||||
|
||||
export const DashboardPage = () => {
|
||||
const setLoopForm = useAppStore((s) => s.setLoopForm);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1.2fr_1fr]">
|
||||
<TorrentTable />
|
||||
<div className="space-y-4">
|
||||
<TorrentDetailsCard />
|
||||
<LoopStatsCard />
|
||||
<LoopSetupCard />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1.2fr_1fr]">
|
||||
<LogsPanel />
|
||||
<div className="space-y-4">
|
||||
<ProfilesCard
|
||||
onApply={(profile) => {
|
||||
setLoopForm({
|
||||
allowIp: profile.allowIp,
|
||||
delayMs: profile.delayMs,
|
||||
targetLoops: profile.targetLoops,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
47
apps/web/src/pages/LoginPage.tsx
Normal file
47
apps/web/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React, { useState } from "react";
|
||||
import { useAuthStore } from "../store/useAuthStore";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/Card";
|
||||
import { Input } from "../components/ui/Input";
|
||||
import { Button } from "../components/ui/Button";
|
||||
|
||||
export const LoginPage = () => {
|
||||
const login = useAuthStore((s) => s.login);
|
||||
const loading = useAuthStore((s) => s.loading);
|
||||
const error = useAuthStore((s) => s.error);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const onSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
await login(username, password);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dashboard-bg flex min-h-screen items-center justify-center px-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>q-buffer Login</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="space-y-4" onSubmit={onSubmit}>
|
||||
<Input
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
{error && <div className="text-sm text-rose-600">{error}</div>}
|
||||
<Button className="w-full" type="submit" disabled={loading}>
|
||||
{loading ? "Signing in..." : "Sign In"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
530
apps/web/src/pages/TimerPage.tsx
Normal file
530
apps/web/src/pages/TimerPage.tsx
Normal file
@@ -0,0 +1,530 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/Card";
|
||||
import { Button } from "../components/ui/Button";
|
||||
import { Input } from "../components/ui/Input";
|
||||
import { api } from "../api/client";
|
||||
import { useAppStore } from "../store/useAppStore";
|
||||
import { useUiStore } from "../store/useUiStore";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "../components/ui/AlertDialog";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faClockRotateLeft,
|
||||
faClock,
|
||||
faTags,
|
||||
faTrash,
|
||||
faChartBar,
|
||||
faPlus,
|
||||
faHourglassHalf,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
const unitOptions = [
|
||||
{ label: "Saat", value: "hours", seconds: 3600 },
|
||||
{ label: "Gün", value: "days", seconds: 86400 },
|
||||
{ label: "Hafta", value: "weeks", seconds: 604800 },
|
||||
] as const;
|
||||
|
||||
const formatBytes = (value: number) => {
|
||||
if (!Number.isFinite(value)) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let size = value;
|
||||
let unitIndex = 0;
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
if (!Number.isFinite(seconds)) return "0 sn";
|
||||
if (seconds >= 86400) {
|
||||
return `${(seconds / 86400).toFixed(1)} gün`;
|
||||
}
|
||||
if (seconds >= 3600) {
|
||||
return `${(seconds / 3600).toFixed(1)} saat`;
|
||||
}
|
||||
return `${Math.max(1, Math.round(seconds / 60))} dk`;
|
||||
};
|
||||
|
||||
const formatCountdown = (seconds: number) => {
|
||||
if (!Number.isFinite(seconds)) return "—";
|
||||
const clamped = Math.max(0, Math.floor(seconds));
|
||||
const days = Math.floor(clamped / 86400);
|
||||
const hours = Math.floor((clamped % 86400) / 3600);
|
||||
const minutes = Math.floor((clamped % 3600) / 60);
|
||||
const secs = clamped % 60;
|
||||
const pad = (value: number) => value.toString().padStart(2, "0");
|
||||
return `${days}g ${pad(hours)}:${pad(minutes)}:${pad(secs)}`;
|
||||
};
|
||||
|
||||
const trackerLabel = (tracker?: string) => {
|
||||
if (!tracker) return "Bilinmiyor";
|
||||
try {
|
||||
const host = new URL(tracker).hostname;
|
||||
return host.replace(/^www\./, "");
|
||||
} catch {
|
||||
return tracker;
|
||||
}
|
||||
};
|
||||
|
||||
export const TimerPage = () => {
|
||||
const torrents = useAppStore((s) => s.torrents);
|
||||
const timerRules = useAppStore((s) => s.timerRules);
|
||||
const timerLogs = useAppStore((s) => s.timerLogs);
|
||||
const timerSummary = useAppStore((s) => s.timerSummary);
|
||||
const setTimerRules = useAppStore((s) => s.setTimerRules);
|
||||
const setTimerLogs = useAppStore((s) => s.setTimerLogs);
|
||||
const setTimerSummary = useAppStore((s) => s.setTimerSummary);
|
||||
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [seedValue, setSeedValue] = useState(2);
|
||||
const [seedUnit, setSeedUnit] = useState<(typeof unitOptions)[number]["value"]>(
|
||||
"weeks"
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const pushAlert = useUiStore((s) => s.pushAlert);
|
||||
const [nowTick, setNowTick] = useState(() => Date.now());
|
||||
|
||||
const tagOptions = useMemo(() => {
|
||||
const tags = new Set<string>();
|
||||
torrents.forEach((torrent) => {
|
||||
const tagList = torrent.tags
|
||||
? torrent.tags.split(",").map((tag) => tag.trim())
|
||||
: [];
|
||||
tagList.filter(Boolean).forEach((tag) => tags.add(tag));
|
||||
if (torrent.category) {
|
||||
tags.add(torrent.category);
|
||||
}
|
||||
});
|
||||
return Array.from(tags.values()).sort((a, b) => a.localeCompare(b));
|
||||
}, [torrents]);
|
||||
|
||||
const rulesForDisplay = useMemo(() => {
|
||||
return [...timerRules].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}, [timerRules]);
|
||||
|
||||
const matchingTorrents = useMemo(() => {
|
||||
if (timerRules.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return torrents
|
||||
.map((torrent) => {
|
||||
const tags = (torrent.tags ?? "")
|
||||
.split(",")
|
||||
.map((tag) => tag.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
if (torrent.category) {
|
||||
tags.push(torrent.category.toLowerCase());
|
||||
}
|
||||
const addedOnMs = Number(torrent.added_on ?? 0) * 1000;
|
||||
const matchingRules = timerRules.filter((rule) => {
|
||||
return rule.tags.some((tag) => tags.includes(tag.toLowerCase()));
|
||||
});
|
||||
if (matchingRules.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const rule = matchingRules.reduce((best, current) =>
|
||||
current.seedLimitSeconds < best.seedLimitSeconds ? current : best
|
||||
);
|
||||
const ruleCreatedAtMs = Date.parse(rule.createdAt);
|
||||
let baseMs = addedOnMs || nowTick;
|
||||
if (Number.isFinite(ruleCreatedAtMs) && ruleCreatedAtMs > baseMs) {
|
||||
baseMs = ruleCreatedAtMs;
|
||||
}
|
||||
const elapsedSeconds = Math.max(0, (nowTick - baseMs) / 1000);
|
||||
const remainingSeconds = rule.seedLimitSeconds - elapsedSeconds;
|
||||
return {
|
||||
torrent,
|
||||
rule,
|
||||
remainingSeconds,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as Array<{
|
||||
torrent: typeof torrents[number];
|
||||
rule: typeof timerRules[number];
|
||||
remainingSeconds: number;
|
||||
}>;
|
||||
}, [timerRules, torrents, nowTick]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const load = async () => {
|
||||
try {
|
||||
const [rulesRes, logsRes, summaryRes] = await Promise.all([
|
||||
api.get("/api/timer/rules"),
|
||||
api.get("/api/timer/logs"),
|
||||
api.get("/api/timer/summary"),
|
||||
]);
|
||||
if (!active) return;
|
||||
setTimerRules(rulesRes.data ?? []);
|
||||
setTimerLogs((logsRes.data ?? []).reverse());
|
||||
if (summaryRes.data) {
|
||||
setTimerSummary(summaryRes.data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (active) {
|
||||
pushAlert({
|
||||
title: "Timer verileri alınamadı",
|
||||
description: "Bağlantıyı kontrol edip tekrar deneyin.",
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
load();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [setTimerLogs, setTimerRules, setTimerSummary]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setNowTick(Date.now()), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
setSelectedTags((current) =>
|
||||
current.includes(tag)
|
||||
? current.filter((value) => value !== tag)
|
||||
: [...current, tag]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSaveRule = async () => {
|
||||
const unit = unitOptions.find((item) => item.value === seedUnit);
|
||||
if (!unit) return;
|
||||
if (selectedTags.length === 0) {
|
||||
pushAlert({
|
||||
title: "Etiket seçilmedi",
|
||||
description: "En az bir etiket seçmelisiniz.",
|
||||
variant: "warn",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(seedValue) || seedValue <= 0) {
|
||||
pushAlert({
|
||||
title: "Seed süresi geçersiz",
|
||||
description: "Seed süresi 0’dan büyük olmalı.",
|
||||
variant: "warn",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const seedLimitSeconds = Math.round(seedValue * unit.seconds);
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await api.post("/api/timer/rules", {
|
||||
tags: selectedTags,
|
||||
seedLimitSeconds,
|
||||
});
|
||||
setTimerRules([response.data, ...timerRules]);
|
||||
setSelectedTags([]);
|
||||
pushAlert({
|
||||
title: "Kural kaydedildi",
|
||||
description: "Timer kuralı aktif edildi.",
|
||||
variant: "success",
|
||||
});
|
||||
} catch (err) {
|
||||
pushAlert({
|
||||
title: "Kural kaydedilemedi",
|
||||
description: "Lütfen daha sonra tekrar deneyin.",
|
||||
variant: "error",
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRule = async (ruleId: string) => {
|
||||
try {
|
||||
await api.delete(`/api/timer/rules/${ruleId}`);
|
||||
setTimerRules(timerRules.filter((rule) => rule.id !== ruleId));
|
||||
pushAlert({
|
||||
title: "Kural silindi",
|
||||
description: "Timer kuralı kaldırıldı.",
|
||||
variant: "success",
|
||||
});
|
||||
} catch (err) {
|
||||
pushAlert({
|
||||
title: "Kural silinemedi",
|
||||
description: "Lütfen daha sonra tekrar deneyin.",
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const summary = timerSummary ?? {
|
||||
totalDeleted: 0,
|
||||
totalSeededSeconds: 0,
|
||||
totalUploadedBytes: 0,
|
||||
updatedAt: "",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1.25fr_0.9fr]">
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faHourglassHalf} className="text-slate-400" />
|
||||
Zamanlayıcı Torrentleri
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{matchingTorrents.length === 0 ? (
|
||||
<div className="text-sm text-slate-500">
|
||||
Bu kurallara bağlı aktif torrent bulunamadı.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{matchingTorrents.map(({ torrent, rule, remainingSeconds }) => (
|
||||
<div
|
||||
key={torrent.hash}
|
||||
className="rounded-lg border border-slate-200 bg-white px-3 py-2"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div
|
||||
className="text-sm font-semibold text-slate-900"
|
||||
title={torrent.name}
|
||||
>
|
||||
{torrent.name}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{formatBytes(torrent.size)} • {trackerLabel(torrent.tracker)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-xs text-slate-600">
|
||||
<div className="font-semibold text-slate-900">
|
||||
{formatCountdown(remainingSeconds)}
|
||||
</div>
|
||||
<div>Kural: {formatDuration(rule.seedLimitSeconds)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-slate-600">
|
||||
<span>Hash: {torrent.hash.slice(0, 12)}...</span>
|
||||
<span>
|
||||
Etiket:{" "}
|
||||
{(torrent.tags || torrent.category || "-")
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean)
|
||||
.join(", ") || "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faTrash} className="text-slate-400" />
|
||||
Silinen Torrent Logları
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{timerLogs.length === 0 ? (
|
||||
<div className="text-sm text-slate-500">Henüz log yok.</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{timerLogs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className="rounded-lg border border-slate-200 bg-white px-3 py-2"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-slate-900">
|
||||
{log.name}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{formatBytes(log.sizeBytes)} •{" "}
|
||||
{trackerLabel(log.tracker)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">
|
||||
{new Date(log.deletedAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-slate-600">
|
||||
<span>Seed: {formatDuration(log.seedingTimeSeconds)}</span>
|
||||
<span>Upload: {formatBytes(log.uploadedBytes)}</span>
|
||||
{log.tags?.length ? (
|
||||
<span>Tags: {log.tags.join(", ")}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faPlus} className="text-slate-400" />
|
||||
Timer Kuralı Oluştur
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-slate-500">
|
||||
<FontAwesomeIcon icon={faTags} className="text-slate-400" />
|
||||
Etiketler
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{tagOptions.length === 0 ? (
|
||||
<div className="text-sm text-slate-500">
|
||||
Henüz etiket bulunamadı.
|
||||
</div>
|
||||
) : (
|
||||
tagOptions.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
onClick={() => toggleTag(tag)}
|
||||
className={`rounded-full border px-3 py-1 text-xs font-semibold ${
|
||||
selectedTags.includes(tag)
|
||||
? "border-slate-900 bg-slate-900 text-white"
|
||||
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300"
|
||||
}`}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-slate-500">
|
||||
<FontAwesomeIcon icon={faClock} className="text-slate-400" />
|
||||
Seed Süresi
|
||||
</div>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={seedValue}
|
||||
onChange={(event) => setSeedValue(Number(event.target.value))}
|
||||
/>
|
||||
<select
|
||||
value={seedUnit}
|
||||
onChange={(event) =>
|
||||
setSeedUnit(event.target.value as typeof seedUnit)
|
||||
}
|
||||
className="h-10 rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-700"
|
||||
>
|
||||
{unitOptions.map((unit) => (
|
||||
<option key={unit.value} value={unit.value}>
|
||||
{unit.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleSaveRule} disabled={busy}>
|
||||
{busy ? "Kaydediliyor..." : "Kuralı Kaydet"}
|
||||
</Button>
|
||||
<div className="border-t border-slate-200 pt-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-xs font-semibold text-slate-500">
|
||||
<FontAwesomeIcon icon={faClockRotateLeft} className="text-slate-400" />
|
||||
Eklenen Kurallar
|
||||
</div>
|
||||
{rulesForDisplay.length === 0 ? (
|
||||
<div className="text-sm text-slate-500">
|
||||
Henüz kural yok.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{rulesForDisplay.map((rule) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm"
|
||||
>
|
||||
<div>
|
||||
<div className="font-semibold text-slate-900">
|
||||
{rule.tags.join(", ")}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
Seed limiti: {formatDuration(rule.seedLimitSeconds)}
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-rose-600 hover:text-rose-700"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} className="mr-2" />
|
||||
Sil
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Kural silinsin mi?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Bu kural kaldırılınca zamanlayıcı artık bu etiketleri izlemeyecek.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>İptal</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => handleDeleteRule(rule.id)}>
|
||||
Sil
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faChartBar} className="text-slate-400" />
|
||||
Timer Özeti
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-slate-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Silinen dosya sayısı</span>
|
||||
<span className="font-semibold text-slate-900">
|
||||
{summary.totalDeleted}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Toplam seed süresi</span>
|
||||
<span className="font-semibold text-slate-900">
|
||||
{formatDuration(summary.totalSeededSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Toplam upload</span>
|
||||
<span className="font-semibold text-slate-900">
|
||||
{formatBytes(summary.totalUploadedBytes)}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
67
apps/web/src/socket/socket.ts
Normal file
67
apps/web/src/socket/socket.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { io } from "socket.io-client";
|
||||
import { useAppStore } from "../store/useAppStore";
|
||||
import { api } from "../api/client";
|
||||
|
||||
let socket: ReturnType<typeof io> | null = null;
|
||||
|
||||
export const connectSocket = () => {
|
||||
if (socket) {
|
||||
return socket;
|
||||
}
|
||||
const baseUrl = import.meta.env.VITE_API_BASE || undefined;
|
||||
socket = io(baseUrl, {
|
||||
withCredentials: true,
|
||||
autoConnect: false,
|
||||
});
|
||||
|
||||
api
|
||||
.get("/api/auth/socket-token")
|
||||
.then((response) => {
|
||||
socket!.auth = { token: response.data.token };
|
||||
socket!.connect();
|
||||
})
|
||||
.catch(() => {
|
||||
socket!.connect();
|
||||
});
|
||||
|
||||
socket.on("status:snapshot", (snapshot) => {
|
||||
useAppStore.getState().setSnapshot(snapshot);
|
||||
});
|
||||
|
||||
socket.on("status:update", (snapshot) => {
|
||||
useAppStore.getState().updateStatus(snapshot);
|
||||
});
|
||||
|
||||
socket.on("job:metrics", (job) => {
|
||||
const state = useAppStore.getState();
|
||||
const jobs = state.jobs.map((existing) =>
|
||||
existing.id === job.id ? job : existing
|
||||
);
|
||||
state.updateStatus({ jobs });
|
||||
});
|
||||
|
||||
socket.on("job:log", (log) => {
|
||||
useAppStore.getState().addLog(log);
|
||||
});
|
||||
|
||||
socket.on("qbit:health", (qbit) => {
|
||||
useAppStore.getState().updateStatus({ qbit });
|
||||
});
|
||||
|
||||
socket.on("timer:log", (log) => {
|
||||
useAppStore.getState().addTimerLog(log);
|
||||
});
|
||||
|
||||
socket.on("timer:summary", (summary) => {
|
||||
useAppStore.getState().setTimerSummary(summary);
|
||||
});
|
||||
|
||||
return socket;
|
||||
};
|
||||
|
||||
export const disconnectSocket = () => {
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
socket = null;
|
||||
}
|
||||
};
|
||||
140
apps/web/src/store/useAppStore.ts
Normal file
140
apps/web/src/store/useAppStore.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export interface TorrentInfo {
|
||||
hash: string;
|
||||
name: string;
|
||||
size: number;
|
||||
progress: number;
|
||||
dlspeed: number;
|
||||
state: string;
|
||||
magnet_uri?: string;
|
||||
tracker?: string;
|
||||
tags?: string;
|
||||
category?: string;
|
||||
added_on?: number;
|
||||
seeding_time?: number;
|
||||
}
|
||||
|
||||
export interface LoopJob {
|
||||
id: string;
|
||||
torrentHash: string;
|
||||
name: string;
|
||||
sizeBytes: number;
|
||||
allowIp: string;
|
||||
targetLoops: number;
|
||||
doneLoops: number;
|
||||
delayMs: number;
|
||||
status: string;
|
||||
bans: { bannedIps: string[] };
|
||||
nextRunAt?: string;
|
||||
lastError?: string;
|
||||
totals?: { totalDownloadedBytes: number };
|
||||
}
|
||||
|
||||
export interface StatusSnapshot {
|
||||
qbit: { ok: boolean; version?: string; lastError?: string };
|
||||
torrents: TorrentInfo[];
|
||||
transfer?: any;
|
||||
jobs: LoopJob[];
|
||||
}
|
||||
|
||||
export interface JobLog {
|
||||
jobId: string;
|
||||
level: "INFO" | "WARN" | "ERROR";
|
||||
message: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TimerRule {
|
||||
id: string;
|
||||
tags: string[];
|
||||
seedLimitSeconds: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TimerLog {
|
||||
id: string;
|
||||
hash: string;
|
||||
name: string;
|
||||
sizeBytes: number;
|
||||
tracker?: string;
|
||||
tags: string[];
|
||||
category?: string;
|
||||
seedingTimeSeconds: number;
|
||||
uploadedBytes: number;
|
||||
deletedAt: string;
|
||||
}
|
||||
|
||||
export interface TimerSummary {
|
||||
totalDeleted: number;
|
||||
totalSeededSeconds: number;
|
||||
totalUploadedBytes: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
qbit: StatusSnapshot["qbit"];
|
||||
torrents: TorrentInfo[];
|
||||
transfer: any;
|
||||
jobs: LoopJob[];
|
||||
logs: JobLog[];
|
||||
timerRules: TimerRule[];
|
||||
timerLogs: TimerLog[];
|
||||
timerSummary: TimerSummary | null;
|
||||
selectedHash: string | null;
|
||||
loopForm: { allowIp: string; delayMs: number; targetLoops: number };
|
||||
setSnapshot: (snapshot: StatusSnapshot) => void;
|
||||
updateStatus: (snapshot: Partial<StatusSnapshot>) => void;
|
||||
addLog: (log: JobLog) => void;
|
||||
setTimerRules: (rules: TimerRule[]) => void;
|
||||
setTimerLogs: (logs: TimerLog[]) => void;
|
||||
addTimerLog: (log: TimerLog) => void;
|
||||
setTimerSummary: (summary: TimerSummary) => void;
|
||||
selectHash: (hash: string) => void;
|
||||
setLoopForm: (partial: Partial<AppState["loopForm"]>) => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
qbit: { ok: false },
|
||||
torrents: [],
|
||||
transfer: null,
|
||||
jobs: [],
|
||||
logs: [],
|
||||
timerRules: [],
|
||||
timerLogs: [],
|
||||
timerSummary: null,
|
||||
selectedHash: null,
|
||||
loopForm: { allowIp: "", delayMs: 3000, targetLoops: 3 },
|
||||
setSnapshot: (snapshot) =>
|
||||
set((state) => ({
|
||||
qbit: snapshot.qbit,
|
||||
torrents: snapshot.torrents,
|
||||
transfer: snapshot.transfer,
|
||||
jobs: snapshot.jobs,
|
||||
selectedHash:
|
||||
state.selectedHash ?? snapshot.torrents?.[0]?.hash ?? null,
|
||||
})),
|
||||
updateStatus: (snapshot) =>
|
||||
set((state) => ({
|
||||
qbit: snapshot.qbit ?? state.qbit,
|
||||
torrents: snapshot.torrents ?? state.torrents,
|
||||
transfer: snapshot.transfer ?? state.transfer,
|
||||
jobs: snapshot.jobs ?? state.jobs,
|
||||
})),
|
||||
addLog: (log) =>
|
||||
set((state) => ({
|
||||
logs: [...state.logs, log].slice(-500),
|
||||
})),
|
||||
setTimerRules: (rules) => set({ timerRules: rules }),
|
||||
setTimerLogs: (logs) => set({ timerLogs: logs }),
|
||||
addTimerLog: (log) =>
|
||||
set((state) => ({
|
||||
timerLogs: [log, ...state.timerLogs].slice(0, 500),
|
||||
})),
|
||||
setTimerSummary: (summary) => set({ timerSummary: summary }),
|
||||
selectHash: (hash) => set({ selectedHash: hash }),
|
||||
setLoopForm: (partial) =>
|
||||
set((state) => ({
|
||||
loopForm: { ...state.loopForm, ...partial },
|
||||
})),
|
||||
}));
|
||||
40
apps/web/src/store/useAuthStore.ts
Normal file
40
apps/web/src/store/useAuthStore.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { create } from "zustand";
|
||||
import { api } from "../api/client";
|
||||
|
||||
interface AuthState {
|
||||
username: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
login: (username: string, password: string) => Promise<boolean>;
|
||||
logout: () => Promise<void>;
|
||||
check: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
username: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
login: async (username, password) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await api.post("/api/auth/login", { username, password });
|
||||
set({ username: response.data.username, loading: false });
|
||||
return true;
|
||||
} catch (error) {
|
||||
set({ error: "Login failed", loading: false });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
logout: async () => {
|
||||
await api.post("/api/auth/logout");
|
||||
set({ username: null });
|
||||
},
|
||||
check: async () => {
|
||||
try {
|
||||
const response = await api.get("/api/auth/me");
|
||||
set({ username: response.data.username ?? "session" });
|
||||
} catch (error) {
|
||||
set({ username: null });
|
||||
}
|
||||
},
|
||||
}));
|
||||
31
apps/web/src/store/useUiStore.ts
Normal file
31
apps/web/src/store/useUiStore.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export type AlertVariant = "default" | "success" | "warn" | "error";
|
||||
|
||||
export interface UiAlert {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
variant: AlertVariant;
|
||||
}
|
||||
|
||||
interface UiState {
|
||||
alerts: UiAlert[];
|
||||
pushAlert: (alert: Omit<UiAlert, "id">) => void;
|
||||
removeAlert: (id: string) => void;
|
||||
}
|
||||
|
||||
const generateId = () => Math.random().toString(36).slice(2);
|
||||
|
||||
export const useUiStore = create<UiState>((set, get) => ({
|
||||
alerts: [],
|
||||
pushAlert: (alert) => {
|
||||
const id = generateId();
|
||||
set((state) => ({ alerts: [{ ...alert, id }, ...state.alerts].slice(0, 5) }));
|
||||
setTimeout(() => {
|
||||
get().removeAlert(id);
|
||||
}, 3000);
|
||||
},
|
||||
removeAlert: (id) =>
|
||||
set((state) => ({ alerts: state.alerts.filter((item) => item.id !== id) })),
|
||||
}));
|
||||
14
apps/web/tailwind.config.cjs
Normal file
14
apps/web/tailwind.config.cjs
Normal file
@@ -0,0 +1,14 @@
|
||||
module.exports = {
|
||||
content: ["./src/**/*.{ts,tsx}", "./index.html"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
ink: "#0f172a",
|
||||
fog: "#e2e8f0",
|
||||
mint: "#14b8a6",
|
||||
steel: "#94a3b8"
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
13
apps/web/tsconfig.json
Normal file
13
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
15
apps/web/vite.config.ts
Normal file
15
apps/web/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: path.resolve(__dirname, "../server/public"),
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: Number(process.env.WEB_PORT) || 5173,
|
||||
},
|
||||
});
|
||||
22
docker-compose.yml
Normal file
22
docker-compose.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
version: "3.9"
|
||||
services:
|
||||
server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "${SERVER_PORT:-3001}:3001"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- QBIT_BASE_URL=${QBIT_BASE_URL}
|
||||
- QBIT_USERNAME=${QBIT_USERNAME}
|
||||
- QBIT_PASSWORD=${QBIT_PASSWORD}
|
||||
- APP_USERNAME=${APP_USERNAME}
|
||||
- APP_PASSWORD=${APP_PASSWORD}
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- POLL_INTERVAL_MS=${POLL_INTERVAL_MS}
|
||||
- ENFORCE_INTERVAL_MS=${ENFORCE_INTERVAL_MS}
|
||||
- DEFAULT_DELAY_MS=${DEFAULT_DELAY_MS}
|
||||
- MAX_LOOP_LIMIT=${MAX_LOOP_LIMIT}
|
||||
13
package.json
Normal file
13
package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "q-buffer",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.0.0",
|
||||
"workspaces": [
|
||||
"apps/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "pnpm -C apps/server dev & pnpm -C apps/web dev --host 0.0.0.0",
|
||||
"build": "pnpm -C apps/server build && pnpm -C apps/web build",
|
||||
"start": "pnpm -C apps/server start"
|
||||
}
|
||||
}
|
||||
5316
pnpm-lock.yaml
generated
Normal file
5316
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
- "apps/*"
|
||||
Reference in New Issue
Block a user