feat(mse): cookie auth, bot detail page, chart with markers
- Cookie-based auth: bcrypt password hash, SQLite sessions - Multipage routing: /, /dashboard, /bots/:id with server-side auth check - bot_logs table + SSE log streaming per bot - Bot detail page: Binance chart (LW Charts), position markers/price lines, live log terminal - Chart always uses live Binance data regardless of bot mode - Position buy markers on correct candle via timeframe offset - Open positions: green target price line; closed: buy/sell arrow markers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e2e5b2e6c6
commit
3a54d7f437
16 changed files with 1354 additions and 85 deletions
|
|
@ -2,6 +2,7 @@ BINANCE_API_KEY=your-live-api-key
|
|||
BINANCE_API_SECRET=your-live-api-secret
|
||||
BINANCE_TESTNET_API_KEY=your-testnet-api-key
|
||||
BINANCE_TESTNET_API_SECRET=your-testnet-api-secret
|
||||
AUTH_TOKEN=your-dashboard-access-token
|
||||
# bcrypt hash — üretmek için: htpasswd -bnBC 10 "" sifreniz | tr -d ':\n' | sed 's/$2y/$2b/'
|
||||
ADMIN_PASSWORD_HASH=$2b$10$example_hash_here
|
||||
DB_PATH=data/bots.db
|
||||
LISTEN_ADDR=127.0.0.1:4646
|
||||
|
|
|
|||
192
Cargo.lock
generated
192
Cargo.lock
generated
|
|
@ -88,6 +88,28 @@ version = "1.0.102"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "async-stream"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
|
||||
dependencies = [
|
||||
"async-stream-impl",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-stream-impl"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
|
|
@ -167,6 +189,30 @@ dependencies = [
|
|||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-extra"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"axum-core",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"fastrand",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"multer",
|
||||
"pin-project-lite",
|
||||
"serde",
|
||||
"tower 0.5.3",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-macros"
|
||||
version = "0.4.2"
|
||||
|
|
@ -184,6 +230,19 @@ version = "0.22.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bcrypt"
|
||||
version = "0.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e65938ed058ef47d92cf8b346cc76ef48984572ade631927e9937b5ffc7662c7"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"blowfish",
|
||||
"getrandom 0.2.17",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
|
|
@ -199,6 +258,16 @@ dependencies = [
|
|||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blowfish"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.2"
|
||||
|
|
@ -253,12 +322,33 @@ dependencies = [
|
|||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
"time",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
|
|
@ -300,6 +390,15 @@ version = "2.10.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
|
|
@ -328,6 +427,15 @@ version = "0.15.7"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_filter"
|
||||
version = "1.0.1"
|
||||
|
|
@ -379,6 +487,12 @@ version = "0.1.9"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
|
|
@ -812,6 +926,15 @@ dependencies = [
|
|||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.0"
|
||||
|
|
@ -970,7 +1093,10 @@ name = "mukan-special-edition"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
"axum",
|
||||
"axum-extra",
|
||||
"bcrypt",
|
||||
"chrono",
|
||||
"dotenvy",
|
||||
"env_logger",
|
||||
|
|
@ -992,6 +1118,29 @@ dependencies = [
|
|||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multer"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-util",
|
||||
"http",
|
||||
"httparse",
|
||||
"memchr",
|
||||
"mime",
|
||||
"spin",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
|
|
@ -1084,6 +1233,12 @@ dependencies = [
|
|||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
|
|
@ -1586,6 +1741,12 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
|
|
@ -1669,6 +1830,37 @@ dependencies = [
|
|||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"itoa",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.3"
|
||||
|
|
|
|||
|
|
@ -54,3 +54,10 @@ futures-core = "0.3"
|
|||
# WebSocket
|
||||
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-native-roots"] }
|
||||
futures-util = "0.3"
|
||||
|
||||
# Auth
|
||||
bcrypt = "0.15"
|
||||
axum-extra = { version = "0.9", features = ["cookie"] }
|
||||
|
||||
# Async stream generator
|
||||
async-stream = "0.3"
|
||||
|
|
|
|||
|
|
@ -1,35 +1,97 @@
|
|||
use axum::{
|
||||
extract::{Query, Request, State},
|
||||
extract::{Request, State},
|
||||
http::StatusCode,
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use axum_extra::extract::CookieJar;
|
||||
use axum_extra::extract::cookie::{Cookie, SameSite};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
const SESSION_COOKIE: &str = "mse_session";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TokenQuery {
|
||||
pub token: Option<String>,
|
||||
pub struct LoginRequest {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<(CookieJar, impl IntoResponse), StatusCode> {
|
||||
let valid = bcrypt::verify(&req.password, &state.password_hash)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !valid {
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
let token = Uuid::new_v4().to_string();
|
||||
|
||||
{
|
||||
let db = state.db.lock().await;
|
||||
db.insert_session(&token).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
}
|
||||
|
||||
let cookie = Cookie::build((SESSION_COOKIE, token))
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Strict)
|
||||
.build();
|
||||
|
||||
Ok((jar.add(cookie), Json(LoginResponse { ok: true })))
|
||||
}
|
||||
|
||||
pub async fn logout(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
) -> impl IntoResponse {
|
||||
if let Some(cookie) = jar.get(SESSION_COOKIE) {
|
||||
let token = cookie.value().to_string();
|
||||
let db = state.db.lock().await;
|
||||
db.delete_session(&token).ok();
|
||||
}
|
||||
|
||||
let removal = Cookie::build((SESSION_COOKIE, ""))
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Strict)
|
||||
.build();
|
||||
|
||||
(jar.remove(removal), Json(LoginResponse { ok: true }))
|
||||
}
|
||||
|
||||
pub async fn require_auth(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<TokenQuery>,
|
||||
jar: CookieJar,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let header_token = request
|
||||
.headers()
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.map(|s| s.to_string());
|
||||
let token = jar
|
||||
.get(SESSION_COOKIE)
|
||||
.map(|c| c.value().to_string());
|
||||
|
||||
let token = header_token.or(query.token);
|
||||
|
||||
match token.as_deref() {
|
||||
Some(t) if t == state.auth_token => Ok(next.run(request).await),
|
||||
_ => Err(StatusCode::UNAUTHORIZED),
|
||||
match token {
|
||||
Some(t) => {
|
||||
let db = state.db.lock().await;
|
||||
let exists = db.session_exists(&t).unwrap_or(false);
|
||||
drop(db);
|
||||
if exists {
|
||||
Ok(next.run(request).await)
|
||||
} else {
|
||||
Err(StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
}
|
||||
None => Err(StatusCode::UNAUTHORIZED),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -156,3 +156,91 @@ pub async fn stop_bot(
|
|||
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
|
||||
pub async fn get_bot(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let db = state.db.lock().await;
|
||||
let bots = match db.get_bots() {
|
||||
Ok(b) => b,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
};
|
||||
let config = match bots.into_iter().find(|b| b.id == id) {
|
||||
Some(c) => c,
|
||||
None => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
drop(db);
|
||||
let manager = state.manager.lock().await;
|
||||
let running = manager.is_running(&id);
|
||||
Json(BotResponse { config, running }).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateBotRequest {
|
||||
pub name: Option<String>,
|
||||
pub usdt_amount: Option<f64>,
|
||||
pub profit_percent: Option<f64>,
|
||||
}
|
||||
|
||||
pub async fn update_bot(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<UpdateBotRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let db = state.db.lock().await;
|
||||
let bots = match db.get_bots() {
|
||||
Ok(b) => b,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
};
|
||||
let mut config = match bots.into_iter().find(|b| b.id == id) {
|
||||
Some(c) => c,
|
||||
None => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
|
||||
if let Some(name) = req.name { config.name = name; }
|
||||
if let Some(usdt) = req.usdt_amount { config.usdt_amount = usdt; }
|
||||
if let Some(profit) = req.profit_percent { config.profit_percent = profit; }
|
||||
|
||||
if let Err(e) = db.update_bot(&config) {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response();
|
||||
}
|
||||
drop(db);
|
||||
|
||||
let manager = state.manager.lock().await;
|
||||
let running = manager.is_running(&id);
|
||||
Json(BotResponse { config, running }).into_response()
|
||||
}
|
||||
|
||||
pub async fn get_bot_logs(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let db = state.db.lock().await;
|
||||
match db.get_logs(&id, 200) {
|
||||
Ok(logs) => Json(logs).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_bot_positions(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let db = state.db.lock().await;
|
||||
match db.get_positions_by_bot(&id) {
|
||||
Ok(p) => Json(p).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_bot_closed(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let db = state.db.lock().await;
|
||||
match db.get_closed_by_bot(&id) {
|
||||
Ok(p) => Json(p).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
use axum::{extract::State, response::sse::{Event, KeepAlive, Sse}};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
response::sse::{Event, KeepAlive, Sse},
|
||||
};
|
||||
use tokio::time::Duration;
|
||||
use tokio_stream::{wrappers::BroadcastStream, StreamExt};
|
||||
|
||||
use crate::AppState;
|
||||
|
|
@ -16,3 +20,24 @@ pub async fn sse_handler(
|
|||
|
||||
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
||||
pub async fn bot_log_sse(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Sse<impl futures_core::Stream<Item = Result<Event, std::convert::Infallible>>> {
|
||||
let stream = async_stream::stream! {
|
||||
let mut last_id: i64 = 0;
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
let db = state.db.lock().await;
|
||||
let logs = db.get_logs_after(&id, last_id).unwrap_or_default();
|
||||
drop(db);
|
||||
for log in logs {
|
||||
last_id = log.id;
|
||||
let data = serde_json::to_string(&log).unwrap_or_default();
|
||||
yield Ok(Event::default().event("log").data(data));
|
||||
}
|
||||
}
|
||||
};
|
||||
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +1,95 @@
|
|||
use axum::{
|
||||
extract::State,
|
||||
middleware,
|
||||
routing::{delete, get, post},
|
||||
response::{IntoResponse, Redirect},
|
||||
routing::{delete, get, post, put},
|
||||
Router,
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
|
||||
use crate::api::{auth::require_auth, bots, events, mode, positions};
|
||||
use crate::api::{auth, bots, events, mode, positions};
|
||||
use crate::AppState;
|
||||
|
||||
pub fn build(state: AppState) -> Router {
|
||||
let api = Router::new()
|
||||
let protected_api = Router::new()
|
||||
.route("/symbols", get(bots::list_symbols))
|
||||
.route("/bots", get(bots::list_bots).post(bots::create_bot))
|
||||
.route("/bots/:id", delete(bots::delete_bot))
|
||||
.route("/bots/:id", get(bots::get_bot).delete(bots::delete_bot).put(bots::update_bot))
|
||||
.route("/bots/:id/start", post(bots::start_bot))
|
||||
.route("/bots/:id/stop", post(bots::stop_bot))
|
||||
.route("/bots/:id/logs", get(bots::get_bot_logs))
|
||||
.route("/bots/:id/positions", get(bots::get_bot_positions))
|
||||
.route("/bots/:id/positions/closed", get(bots::get_bot_closed))
|
||||
.route("/bots/:id/log-stream", get(events::bot_log_sse))
|
||||
.route("/positions", get(positions::open_positions))
|
||||
.route("/positions/closed", get(positions::closed_positions))
|
||||
.route("/mode", get(mode::get_mode).post(mode::set_mode))
|
||||
.route("/events", get(events::sse_handler))
|
||||
.layer(middleware::from_fn_with_state(state.clone(), require_auth));
|
||||
.layer(middleware::from_fn_with_state(state.clone(), auth::require_auth));
|
||||
|
||||
let api = Router::new()
|
||||
.route("/auth/login", post(auth::login))
|
||||
.route("/auth/logout", post(auth::logout))
|
||||
.merge(protected_api);
|
||||
|
||||
Router::new()
|
||||
.nest("/api", api)
|
||||
.route("/", get(index_handler))
|
||||
.route("/", get(login_handler))
|
||||
.route("/dashboard", get(dashboard_handler))
|
||||
.route("/bots/:id", get(bot_detail_handler))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn index_handler() -> axum::response::Html<&'static str> {
|
||||
axum::response::Html(include_str!("../web/index.html"))
|
||||
async fn login_handler(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
) -> impl IntoResponse {
|
||||
let token = jar.get("mse_session").map(|c| c.value().to_string());
|
||||
let authed = match token {
|
||||
Some(t) => {
|
||||
let db = state.db.lock().await;
|
||||
db.session_exists(&t).unwrap_or(false)
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
if authed {
|
||||
Redirect::to("/dashboard").into_response()
|
||||
} else {
|
||||
axum::response::Html(include_str!("../web/login.html")).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
async fn bot_detail_handler(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
) -> impl IntoResponse {
|
||||
let token = jar.get("mse_session").map(|c| c.value().to_string());
|
||||
let authed = match token {
|
||||
Some(t) => { let db = state.db.lock().await; db.session_exists(&t).unwrap_or(false) }
|
||||
None => false,
|
||||
};
|
||||
if authed {
|
||||
axum::response::Html(include_str!("../web/bot.html")).into_response()
|
||||
} else {
|
||||
Redirect::to("/").into_response()
|
||||
}
|
||||
}
|
||||
|
||||
async fn dashboard_handler(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
) -> impl IntoResponse {
|
||||
let token = jar.get("mse_session").map(|c| c.value().to_string());
|
||||
let authed = match token {
|
||||
Some(t) => {
|
||||
let db = state.db.lock().await;
|
||||
db.session_exists(&t).unwrap_or(false)
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
if authed {
|
||||
axum::response::Html(include_str!("../web/index.html")).into_response()
|
||||
} else {
|
||||
Redirect::to("/").into_response()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,10 @@ impl BotRunner {
|
|||
match connect_async(&stream).await {
|
||||
Ok((ws, _)) => {
|
||||
info!("[{}] WS bağlantısı kuruldu.", config.symbol);
|
||||
{
|
||||
let db_guard = db.lock().await;
|
||||
db_guard.insert_log(&config.id, "info", "WS bağlantısı kuruldu.").ok();
|
||||
}
|
||||
let (_, mut read) = ws.split();
|
||||
|
||||
loop {
|
||||
|
|
@ -82,6 +86,10 @@ impl BotRunner {
|
|||
}
|
||||
|
||||
info!("[{}] Mum kapandı. Açılış: {:.6} | Kapanış: {:.6}", config.symbol, kline.open, kline.close);
|
||||
{
|
||||
let db_guard = db.lock().await;
|
||||
db_guard.insert_log(&config.id, "info", &format!("Mum kapandı. Açılış: {:.6} | Kapanış: {:.6}", kline.open, kline.close)).ok();
|
||||
}
|
||||
|
||||
let kline_data = Kline::from(&kline);
|
||||
match RedCandleStrategy::execute(
|
||||
|
|
@ -98,6 +106,10 @@ impl BotRunner {
|
|||
"[{}] ✅ İşlem | Alış: {:.6} | Satış hedefi: {:.6} | Kar: %{:.2}",
|
||||
config.symbol, result.buy_order.price, result.sell_order.price, config.profit_percent
|
||||
);
|
||||
{
|
||||
let db_guard = db.lock().await;
|
||||
db_guard.insert_log(&config.id, "trade", &format!("✅ İşlem gerçekleşti | Alış: {:.6} | Satış hedefi: {:.6} | Miktar: {:.6} | Kar: %{:.2}", result.buy_order.price, result.sell_order.price, result.buy_order.quantity, config.profit_percent)).ok();
|
||||
}
|
||||
|
||||
let record = TradeRecord {
|
||||
bot_id: config.id.clone(),
|
||||
|
|
@ -144,10 +156,18 @@ impl BotRunner {
|
|||
}).ok();
|
||||
}
|
||||
Ok(None) => {
|
||||
info!("[{}] ⏭ İşlem yapılmadı.", config.symbol);
|
||||
info!("[{}] ⏭ İşlem yapılmadı (yeşil mum).", config.symbol);
|
||||
{
|
||||
let db_guard = db.lock().await;
|
||||
db_guard.insert_log(&config.id, "info", "⏭ Yeşil mum, işlem yapılmadı.").ok();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("[{}] ❌ Strateji hatası: {:?}", config.symbol, e);
|
||||
{
|
||||
let db_guard = db.lock().await;
|
||||
db_guard.insert_log(&config.id, "error", &format!("❌ Strateji hatası: {}", e)).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -164,6 +184,10 @@ impl BotRunner {
|
|||
}
|
||||
Err(e) => {
|
||||
error!("[{}] WS bağlanamadı: {}. 5sn sonra tekrar deneniyor.", config.symbol, e);
|
||||
{
|
||||
let db_guard = db.lock().await;
|
||||
db_guard.insert_log(&config.id, "error", &format!("WS bağlanamadı: {}. 5sn sonra tekrar deneniyor.", e)).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ pub struct AppConfig {
|
|||
pub live_api_secret: String,
|
||||
pub testnet_api_key: String,
|
||||
pub testnet_api_secret: String,
|
||||
pub auth_token: String,
|
||||
pub password_hash: String,
|
||||
pub db_path: String,
|
||||
pub listen_addr: String,
|
||||
}
|
||||
|
|
@ -18,7 +18,7 @@ impl AppConfig {
|
|||
live_api_secret: env::var("BINANCE_API_SECRET").expect("BINANCE_API_SECRET gerekli"),
|
||||
testnet_api_key: env::var("BINANCE_TESTNET_API_KEY").expect("BINANCE_TESTNET_API_KEY gerekli"),
|
||||
testnet_api_secret: env::var("BINANCE_TESTNET_API_SECRET").expect("BINANCE_TESTNET_API_SECRET gerekli"),
|
||||
auth_token: env::var("AUTH_TOKEN").expect("AUTH_TOKEN gerekli"),
|
||||
password_hash: env::var("ADMIN_PASSWORD_HASH").expect("ADMIN_PASSWORD_HASH gerekli"),
|
||||
db_path: env::var("DB_PATH").unwrap_or_else(|_| "data/bots.db".to_string()),
|
||||
listen_addr: env::var("LISTEN_ADDR").unwrap_or_else(|_| "127.0.0.1:4646".to_string()),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ pub struct AppState {
|
|||
pub db: Arc<Mutex<Database>>,
|
||||
pub manager: Arc<Mutex<BotManager>>,
|
||||
pub event_tx: broadcast::Sender<TradeEvent>,
|
||||
pub auth_token: String,
|
||||
pub password_hash: String,
|
||||
pub live_api_key: String,
|
||||
pub live_api_secret: String,
|
||||
pub testnet_api_key: String,
|
||||
|
|
@ -79,7 +79,7 @@ async fn main() {
|
|||
db,
|
||||
manager,
|
||||
event_tx,
|
||||
auth_token: cfg.auth_token,
|
||||
password_hash: cfg.password_hash,
|
||||
live_api_key: cfg.live_api_key,
|
||||
live_api_secret: cfg.live_api_secret,
|
||||
testnet_api_key: cfg.testnet_api_key,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use rusqlite::{params, Connection, Result as SqlResult};
|
|||
use crate::storage::closed::ClosedPosition;
|
||||
use crate::storage::config::BotConfig;
|
||||
use crate::storage::history::TradeRecord;
|
||||
use crate::storage::logs::BotLog;
|
||||
use crate::storage::positions::OpenPosition;
|
||||
|
||||
pub struct Database {
|
||||
|
|
@ -70,7 +71,19 @@ impl Database {
|
|||
CREATE TABLE IF NOT EXISTS config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);",
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS bot_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bot_id TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_bot_logs_bot_id ON bot_logs(bot_id, created_at DESC);",
|
||||
)?;
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
|
@ -118,6 +131,14 @@ impl Database {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_bot(&self, b: &BotConfig) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE bots SET name = ?1, usdt_amount = ?2, profit_percent = ?3 WHERE id = ?4",
|
||||
params![b.name, b.usdt_amount, b.profit_percent, b.id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_bot_active(&self, id: &str, active: bool) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE bots SET active = ?1 WHERE id = ?2",
|
||||
|
|
@ -240,6 +261,127 @@ impl Database {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Sessions ────────────────────────────────────
|
||||
|
||||
pub fn insert_session(&self, token: &str) -> SqlResult<()> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
self.conn.execute(
|
||||
"INSERT INTO sessions (token, created_at) VALUES (?1, ?2)",
|
||||
params![token, now],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn session_exists(&self, token: &str) -> SqlResult<bool> {
|
||||
let count: i64 = self.conn.query_row(
|
||||
"SELECT COUNT(*) FROM sessions WHERE token = ?1",
|
||||
params![token],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
pub fn delete_session(&self, token: &str) -> SqlResult<()> {
|
||||
self.conn.execute("DELETE FROM sessions WHERE token = ?1", params![token])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Bot logs ─────────────────────────────────────
|
||||
|
||||
pub fn insert_log(&self, bot_id: &str, level: &str, message: &str) -> SqlResult<()> {
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
self.conn.execute(
|
||||
"INSERT INTO bot_logs (bot_id, level, message, created_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![bot_id, level, message, now],
|
||||
)?;
|
||||
// Son 500 logu tut
|
||||
self.conn.execute(
|
||||
"DELETE FROM bot_logs WHERE bot_id = ?1 AND id NOT IN (SELECT id FROM bot_logs WHERE bot_id = ?1 ORDER BY id DESC LIMIT 500)",
|
||||
params![bot_id, bot_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_logs(&self, bot_id: &str, limit: usize) -> SqlResult<Vec<BotLog>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, bot_id, level, message, created_at FROM bot_logs WHERE bot_id = ?1 ORDER BY id DESC LIMIT ?2",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![bot_id, limit as i64], |row| {
|
||||
Ok(BotLog {
|
||||
id: row.get(0)?,
|
||||
bot_id: row.get(1)?,
|
||||
level: row.get(2)?,
|
||||
message: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
})
|
||||
})?;
|
||||
let mut logs: Vec<BotLog> = rows.collect::<SqlResult<_>>()?;
|
||||
logs.reverse();
|
||||
Ok(logs)
|
||||
}
|
||||
|
||||
pub fn get_logs_after(&self, bot_id: &str, after_id: i64) -> SqlResult<Vec<BotLog>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, bot_id, level, message, created_at FROM bot_logs WHERE bot_id = ?1 AND id > ?2 ORDER BY id ASC",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![bot_id, after_id], |row| {
|
||||
Ok(BotLog {
|
||||
id: row.get(0)?,
|
||||
bot_id: row.get(1)?,
|
||||
level: row.get(2)?,
|
||||
message: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn get_positions_by_bot(&self, bot_id: &str) -> SqlResult<Vec<crate::storage::positions::OpenPosition>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT order_id, bot_id, bot_name, symbol, buy_price, sell_target, quantity, profit_percent, opened_at
|
||||
FROM open_positions WHERE bot_id = ?1 ORDER BY opened_at DESC",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![bot_id], |row| {
|
||||
let order_id: i64 = row.get(0)?;
|
||||
Ok(crate::storage::positions::OpenPosition {
|
||||
order_id: order_id as u64,
|
||||
bot_id: row.get(1)?,
|
||||
bot_name: row.get(2)?,
|
||||
symbol: row.get(3)?,
|
||||
buy_price: row.get(4)?,
|
||||
sell_target: row.get(5)?,
|
||||
quantity: row.get(6)?,
|
||||
profit_percent: row.get(7)?,
|
||||
opened_at: row.get(8)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn get_closed_by_bot(&self, bot_id: &str) -> SqlResult<Vec<crate::storage::closed::ClosedPosition>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT order_id, bot_id, bot_name, symbol, buy_price, sell_target, quantity, profit_percent, opened_at, closed_at, status
|
||||
FROM closed_positions WHERE bot_id = ?1 ORDER BY closed_at DESC LIMIT 100",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![bot_id], |row| {
|
||||
let order_id: i64 = row.get(0)?;
|
||||
Ok(crate::storage::closed::ClosedPosition {
|
||||
order_id: order_id as u64,
|
||||
bot_id: row.get(1)?,
|
||||
bot_name: row.get(2)?,
|
||||
symbol: row.get(3)?,
|
||||
buy_price: row.get(4)?,
|
||||
sell_target: row.get(5)?,
|
||||
quantity: row.get(6)?,
|
||||
profit_percent: row.get(7)?,
|
||||
opened_at: row.get(8)?,
|
||||
closed_at: row.get(9)?,
|
||||
status: row.get(10)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
// ─── Closed positions ────────────────────────────
|
||||
|
||||
pub fn get_closed_positions(&self) -> SqlResult<Vec<ClosedPosition>> {
|
||||
|
|
|
|||
10
src/storage/logs.rs
Normal file
10
src/storage/logs.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct BotLog {
|
||||
pub id: i64,
|
||||
pub bot_id: String,
|
||||
pub level: String,
|
||||
pub message: String,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
|
@ -2,4 +2,5 @@ pub mod closed;
|
|||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod history;
|
||||
pub mod logs;
|
||||
pub mod positions;
|
||||
|
|
|
|||
625
src/web/bot.html
Normal file
625
src/web/bot.html
Normal file
|
|
@ -0,0 +1,625 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="tr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Bot Detay — CryptoFox Mukan Edition</title>
|
||||
<link rel="icon" type="image/png" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAC0AAAAwCAYAAACFUvPfAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAGYktHRAD/AP8A/6C9p5MAAAAJcEhZcwAACxIAAAsSAdLdfvwAAAAHdElNRQfqBBMEIhx4nX5G">
|
||||
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #0f0f13; --surface: #1a1a22; --surface2: #14141b;
|
||||
--border: #2a2a38; --text: #e0e0f0; --muted: #666;
|
||||
--accent: #6c63ff; --green: #2ecc71; --red: #e74c3c; --yellow: #f39c12;
|
||||
}
|
||||
html, body { height: 100%; overflow: hidden; }
|
||||
body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', system-ui, sans-serif; font-size: 13px; display: flex; flex-direction: column; }
|
||||
|
||||
/* ── TOP BAR ─────────────────────────────────── */
|
||||
#topbar {
|
||||
background: var(--surface); border-bottom: 1px solid var(--border);
|
||||
padding: 0 16px; height: 52px; display: flex; align-items: center; gap: 20px; flex-shrink: 0;
|
||||
}
|
||||
.tb-logo { display: flex; align-items: center; gap: 8px; }
|
||||
.tb-logo span { font-size: 15px; font-weight: 700; }
|
||||
.logo-crypto { color: #d0d0d0; } .logo-fox { color: #00d4ff; }
|
||||
.tb-sep { width: 1px; height: 24px; background: var(--border); }
|
||||
#tb-name { font-size: 16px; font-weight: 600; color: var(--text); }
|
||||
#tb-symbol { font-size: 13px; color: var(--muted); }
|
||||
#tb-price { font-size: 20px; font-weight: 700; color: var(--text); min-width: 100px; }
|
||||
#tb-change { font-size: 13px; font-weight: 600; min-width: 60px; }
|
||||
.tb-stat { display: flex; flex-direction: column; gap: 1px; }
|
||||
.tb-stat .lbl { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.tb-stat .val { font-size: 12px; color: var(--text); }
|
||||
.spacer { flex: 1; }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
|
||||
.badge-running { background: rgba(46,204,113,.15); color: var(--green); }
|
||||
.badge-stopped { background: rgba(136,136,136,.1); color: var(--muted); }
|
||||
.btn { padding: 5px 12px; border-radius: 5px; border: 1px solid var(--border); background: transparent; color: var(--text); font-size: 12px; cursor: pointer; transition: all .15s; white-space: nowrap; }
|
||||
.btn:hover { background: rgba(255,255,255,.05); }
|
||||
.btn-start { border-color: var(--green); color: var(--green); }
|
||||
.btn-start:hover { background: rgba(46,204,113,.1); }
|
||||
.btn-stop { border-color: var(--yellow); color: var(--yellow); }
|
||||
.btn-stop:hover { background: rgba(243,156,18,.1); }
|
||||
.btn-delete { border-color: var(--red); color: var(--red); }
|
||||
.btn-delete:hover { background: rgba(231,76,60,.1); }
|
||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.btn-primary:hover { background: #5a52d5; }
|
||||
.back-link { color: var(--muted); font-size: 12px; text-decoration: none; display: flex; align-items: center; gap: 4px; }
|
||||
.back-link:hover { color: var(--text); }
|
||||
|
||||
/* ── MAIN LAYOUT ─────────────────────────────── */
|
||||
#layout {
|
||||
flex: 1; display: grid; min-height: 0;
|
||||
grid-template-columns: 360px 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
/* ── LEFT PANE ───────────────────────────────── */
|
||||
#left-pane {
|
||||
border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
.pane-header {
|
||||
padding: 8px 14px; border-bottom: 1px solid var(--border);
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: .05em; color: var(--muted); display: flex; align-items: center; justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pane-tabs { display: flex; border-bottom: 1px solid var(--border); flex-shrink: 0; }
|
||||
.pane-tab {
|
||||
flex: 1; padding: 9px 8px; text-align: center; font-size: 11px; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: .04em; color: var(--muted);
|
||||
cursor: pointer; border-bottom: 2px solid transparent; transition: all .15s;
|
||||
}
|
||||
.pane-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
.pane-tab:hover:not(.active) { color: var(--text); }
|
||||
.pane-content { overflow-y: auto; }
|
||||
.pane-content::-webkit-scrollbar { width: 4px; }
|
||||
.pane-content::-webkit-scrollbar-track { background: transparent; }
|
||||
.pane-content::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||
|
||||
/* settings section */
|
||||
#settings-section { border-bottom: 1px solid var(--border); flex-shrink: 0; }
|
||||
.form-group { display: flex; flex-direction: column; gap: 4px; }
|
||||
.form-group label { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; }
|
||||
.form-group input {
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: 4px;
|
||||
padding: 6px 9px; color: var(--text); font-size: 12px; width: 100%;
|
||||
}
|
||||
.form-group input:focus { outline: none; border-color: var(--accent); }
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
#save-status { font-size: 10px; color: var(--green); display: none; }
|
||||
|
||||
/* positions */
|
||||
#pos-section { flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
|
||||
.pos-table { width: 100%; border-collapse: collapse; }
|
||||
.pos-table th, .pos-table td { padding: 7px 14px; text-align: right; border-bottom: 1px solid rgba(255,255,255,.04); font-size: 12px; }
|
||||
.pos-table th { font-size: 10px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); font-weight: 500; text-align: right; position: sticky; top: 0; background: var(--surface2); z-index: 1; }
|
||||
.pos-table td:first-child, .pos-table th:first-child { text-align: left; }
|
||||
.pos-table tr:hover td { background: rgba(255,255,255,.02); }
|
||||
.empty-row { padding: 24px 14px; text-align: center; color: var(--muted); font-size: 12px; }
|
||||
.profit-up { color: var(--green); }
|
||||
.profit-down { color: var(--red); }
|
||||
|
||||
/* ── CENTER PANE ─────────────────────────────── */
|
||||
#center-pane { display: flex; flex-direction: column; overflow: hidden; }
|
||||
|
||||
/* chart */
|
||||
#chart-header { padding: 8px 14px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
|
||||
#chart-tf-label { background: rgba(108,99,255,.15); color: var(--accent); padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
|
||||
#chart-ohlc { font-size: 11px; color: var(--muted); display: flex; gap: 10px; }
|
||||
#chart-ohlc span b { color: var(--text); }
|
||||
#chart-container { flex: 6; min-height: 0; position: relative; }
|
||||
#chart { width: 100%; height: 100%; }
|
||||
|
||||
/* log terminal */
|
||||
#log-section { flex: 4; display: flex; flex-direction: column; min-height: 0; border-top: 1px solid var(--border); }
|
||||
#log-terminal {
|
||||
flex: 1; background: #0a0a0e; padding: 10px 12px; overflow-y: auto;
|
||||
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace; font-size: 11px; line-height: 1.7;
|
||||
}
|
||||
#log-terminal::-webkit-scrollbar { width: 4px; }
|
||||
#log-terminal::-webkit-scrollbar-thumb { background: #2a2a38; border-radius: 2px; }
|
||||
.log-line { display: flex; gap: 8px; }
|
||||
.log-time { color: #444; min-width: 70px; flex-shrink: 0; font-size: 10px; padding-top: 1px; }
|
||||
.log-level { min-width: 40px; flex-shrink: 0; font-weight: 700; font-size: 10px; padding-top: 1px; }
|
||||
.log-level.info { color: #6c63ff; }
|
||||
.log-level.trade { color: var(--green); }
|
||||
.log-level.error { color: var(--red); }
|
||||
.log-msg { color: #a0a0b8; word-break: break-word; }
|
||||
#log-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--muted); display: inline-block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- TOP BAR -->
|
||||
<div id="topbar">
|
||||
<div class="tb-logo">
|
||||
<span><span class="logo-crypto">Crypto</span><span class="logo-fox">Fox</span></span>
|
||||
</div>
|
||||
<div class="tb-sep"></div>
|
||||
<div id="tb-name">—</div>
|
||||
<div id="tb-symbol" style="margin-left:4px">—</div>
|
||||
<div class="tb-sep"></div>
|
||||
<div id="tb-price">—</div>
|
||||
<div id="tb-change">—</div>
|
||||
<div class="tb-sep"></div>
|
||||
<div class="tb-stat"><span class="lbl">24s Yüksek</span><span class="val" id="tb-high">—</span></div>
|
||||
<div class="tb-stat"><span class="lbl">24s Düşük</span><span class="val" id="tb-low">—</span></div>
|
||||
<div class="tb-stat"><span class="lbl">24s Hacim</span><span class="val" id="tb-vol">—</span></div>
|
||||
<div class="spacer"></div>
|
||||
<span id="tb-badge"></span>
|
||||
<div id="tb-actions" style="display:flex;gap:8px"></div>
|
||||
<div class="tb-sep"></div>
|
||||
<a href="/dashboard" class="back-link">← Dashboard</a>
|
||||
<button class="btn btn-delete" style="font-size:12px;padding:4px 12px" onclick="logout()">Çıkış</button>
|
||||
</div>
|
||||
|
||||
<!-- MAIN LAYOUT -->
|
||||
<div id="layout">
|
||||
|
||||
<!-- LEFT: Ayarlar + Pozisyonlar -->
|
||||
<div id="left-pane">
|
||||
|
||||
<!-- Settings -->
|
||||
<div id="settings-section">
|
||||
<div class="pane-header">
|
||||
Bot Ayarları
|
||||
<span id="save-status">✓ Kaydedildi</span>
|
||||
</div>
|
||||
<div style="padding:12px;display:flex;flex-direction:column;gap:10px">
|
||||
<div class="form-group">
|
||||
<label>İsim</label>
|
||||
<input id="e-name" type="text" />
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label>USDT Miktarı</label>
|
||||
<input id="e-usdt" type="number" step="1" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Kar %</label>
|
||||
<input id="e-profit" type="number" step="0.1" />
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="saveBot()" style="width:100%">Kaydet</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pozisyonlar -->
|
||||
<div id="pos-section">
|
||||
<div class="pane-header">Pozisyonlar</div>
|
||||
<div class="pane-tabs">
|
||||
<div class="pane-tab active" id="tab-open" onclick="switchTab('open')">Açık</div>
|
||||
<div class="pane-tab" id="tab-closed" onclick="switchTab('closed')">Kapalı</div>
|
||||
</div>
|
||||
<div class="pane-content" id="left-content">
|
||||
<div class="empty-row">Yükleniyor...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- CENTER: Chart + Log -->
|
||||
<div id="center-pane">
|
||||
<div id="chart-header">
|
||||
<span id="chart-tf-label">—</span>
|
||||
<div id="chart-ohlc">
|
||||
<span>A: <b id="ohlc-o">—</b></span>
|
||||
<span>Y: <b id="ohlc-h">—</b></span>
|
||||
<span>D: <b id="ohlc-l">—</b></span>
|
||||
<span>K: <b id="ohlc-c">—</b></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="chart-container">
|
||||
<div id="chart"></div>
|
||||
</div>
|
||||
|
||||
<!-- Log -->
|
||||
<div id="log-section">
|
||||
<div class="pane-header">
|
||||
Canlı Log
|
||||
<div style="display:flex;align-items:center;gap:6px">
|
||||
<span id="log-dot"></span>
|
||||
<span id="log-status" style="font-size:10px;color:var(--muted)">—</span>
|
||||
<button class="btn" style="font-size:10px;padding:2px 7px" onclick="clearLog()">Temizle</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="log-terminal"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const BOT_ID = window.location.pathname.split('/').pop();
|
||||
let bot = null;
|
||||
let logSource = null;
|
||||
let lastLogId = 0;
|
||||
let chart = null;
|
||||
let candleSeries = null;
|
||||
let currentTab = 'open';
|
||||
let priceWs = null;
|
||||
let priceLines = [];
|
||||
let openPositionsCache = [];
|
||||
let closedPositionsCache = [];
|
||||
|
||||
// ── API helper ────────────────────────────────────
|
||||
async function api(method, path, body) {
|
||||
const res = await fetch('/api' + path, {
|
||||
method, credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (res.status === 401) { window.location.href = '/'; throw new Error('Unauthorized'); }
|
||||
return res;
|
||||
}
|
||||
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
function fmt(n, d=2) { return parseFloat(n).toLocaleString('tr-TR', { minimumFractionDigits: d, maximumFractionDigits: d }); }
|
||||
|
||||
// ── Timeframe → Binance interval ─────────────────
|
||||
const TF_MAP = { '1m':'1m','5m':'5m','15m':'15m','30m':'30m','1h':'1h','4h':'4h','1d':'1d','1w':'1w' };
|
||||
const TF_SECONDS = { '1m':60,'5m':300,'15m':900,'30m':1800,'1h':3600,'4h':14400,'1d':86400,'1w':604800 };
|
||||
function buyTime(ms) {
|
||||
const tf = TF_SECONDS[bot.timeframe] || 300;
|
||||
return Math.floor(ms / 1000) - tf;
|
||||
}
|
||||
|
||||
// ── Load bot ──────────────────────────────────────
|
||||
async function loadBot() {
|
||||
const res = await api('GET', `/bots/${BOT_ID}`);
|
||||
if (!res.ok) { window.location.href = '/dashboard'; return; }
|
||||
bot = await res.json();
|
||||
document.title = `${bot.name} — CryptoFox Mukan Edition`;
|
||||
renderTopBar();
|
||||
renderEditForm();
|
||||
document.getElementById('chart-tf-label').textContent = bot.timeframe;
|
||||
initChart();
|
||||
loadTicker();
|
||||
startPriceWs();
|
||||
}
|
||||
|
||||
function renderTopBar() {
|
||||
document.getElementById('tb-name').textContent = bot.name;
|
||||
document.getElementById('tb-symbol').textContent = bot.symbol;
|
||||
document.getElementById('tb-badge').innerHTML = `<span class="badge ${bot.running ? 'badge-running' : 'badge-stopped'}">${bot.running ? 'Çalışıyor' : 'Durdu'}</span>`;
|
||||
const actions = document.getElementById('tb-actions');
|
||||
if (bot.running) {
|
||||
actions.innerHTML = `<button class="btn btn-stop" onclick="stopBot()">Durdur</button><button class="btn btn-delete" onclick="deleteBot()">Sil</button>`;
|
||||
} else {
|
||||
actions.innerHTML = `<button class="btn btn-start" onclick="startBot()">Başlat</button><button class="btn btn-delete" onclick="deleteBot()">Sil</button>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderEditForm() {
|
||||
document.getElementById('e-name').value = bot.name;
|
||||
document.getElementById('e-usdt').value = bot.usdt_amount;
|
||||
document.getElementById('e-profit').value = bot.profit_percent;
|
||||
}
|
||||
|
||||
// ── Bot actions ───────────────────────────────────
|
||||
async function startBot() {
|
||||
await api('POST', `/bots/${BOT_ID}/start`);
|
||||
await loadBot();
|
||||
}
|
||||
async function stopBot() {
|
||||
await api('POST', `/bots/${BOT_ID}/stop`);
|
||||
await loadBot();
|
||||
}
|
||||
async function deleteBot() {
|
||||
if (!confirm(`"${bot.name}" botunu silmek istiyor musun?`)) return;
|
||||
await api('DELETE', `/bots/${BOT_ID}`);
|
||||
window.location.href = '/dashboard';
|
||||
}
|
||||
async function saveBot() {
|
||||
const name = document.getElementById('e-name').value.trim();
|
||||
const usdt_amount = parseFloat(document.getElementById('e-usdt').value);
|
||||
const profit_percent = parseFloat(document.getElementById('e-profit').value);
|
||||
if (!name || !usdt_amount || !profit_percent) { alert('Tüm alanları doldurun'); return; }
|
||||
const res = await api('PUT', `/bots/${BOT_ID}`, { name, usdt_amount, profit_percent });
|
||||
bot = await res.json();
|
||||
renderTopBar();
|
||||
const s = document.getElementById('save-status');
|
||||
s.style.display = 'inline';
|
||||
setTimeout(() => s.style.display = 'none', 2000);
|
||||
}
|
||||
|
||||
// ── Ticker (24h stats) ────────────────────────────
|
||||
async function loadTicker() {
|
||||
try {
|
||||
const res = await fetch(`https://api.binance.com/api/v3/ticker/24hr?symbol=${bot.symbol}`);
|
||||
const t = await res.json();
|
||||
const change = parseFloat(t.priceChangePercent);
|
||||
document.getElementById('tb-price').textContent = fmt(t.lastPrice, 2);
|
||||
const chEl = document.getElementById('tb-change');
|
||||
chEl.textContent = (change >= 0 ? '+' : '') + change.toFixed(2) + '%';
|
||||
chEl.style.color = change >= 0 ? 'var(--green)' : 'var(--red)';
|
||||
document.getElementById('tb-high').textContent = fmt(t.highPrice, 2);
|
||||
document.getElementById('tb-low').textContent = fmt(t.lowPrice, 2);
|
||||
const vol = parseFloat(t.quoteVolume);
|
||||
document.getElementById('tb-vol').textContent = vol >= 1e9 ? (vol/1e9).toFixed(2)+'B' : vol >= 1e6 ? (vol/1e6).toFixed(2)+'M' : fmt(vol, 0);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// ── Live price via Binance WS ─────────────────────
|
||||
function startPriceWs() {
|
||||
if (priceWs) priceWs.close();
|
||||
const sym = bot.symbol.toLowerCase();
|
||||
priceWs = new WebSocket(`wss://stream.binance.com:9443/ws/${sym}@ticker`);
|
||||
priceWs.onmessage = e => {
|
||||
const t = JSON.parse(e.data);
|
||||
const price = parseFloat(t.c);
|
||||
const change = parseFloat(t.P);
|
||||
document.getElementById('tb-price').textContent = fmt(price, 2);
|
||||
const chEl = document.getElementById('tb-change');
|
||||
chEl.textContent = (change >= 0 ? '+' : '') + change.toFixed(2) + '%';
|
||||
chEl.style.color = change >= 0 ? 'var(--green)' : 'var(--red)';
|
||||
};
|
||||
priceWs.onerror = () => {};
|
||||
priceWs.onclose = () => setTimeout(startPriceWs, 5000);
|
||||
}
|
||||
|
||||
// ── Chart ─────────────────────────────────────────
|
||||
function initChart() {
|
||||
const container = document.getElementById('chart');
|
||||
chart = LightweightCharts.createChart(container, {
|
||||
layout: { background: { color: '#0f0f13' }, textColor: '#888' },
|
||||
grid: { vertLines: { color: '#1e1e28' }, horzLines: { color: '#1e1e28' } },
|
||||
crosshair: { mode: LightweightCharts.CrosshairMode.Normal },
|
||||
rightPriceScale: { borderColor: '#2a2a38' },
|
||||
timeScale: { borderColor: '#2a2a38', timeVisible: true, secondsVisible: false },
|
||||
width: container.clientWidth,
|
||||
height: container.clientHeight,
|
||||
});
|
||||
candleSeries = chart.addCandlestickSeries({
|
||||
upColor: '#2ecc71', downColor: '#e74c3c',
|
||||
borderUpColor: '#2ecc71', borderDownColor: '#e74c3c',
|
||||
wickUpColor: '#2ecc71', wickDownColor: '#e74c3c',
|
||||
});
|
||||
|
||||
// Crosshair OHLC güncelle
|
||||
chart.subscribeCrosshairMove(p => {
|
||||
if (!p.seriesData || !p.seriesData.size) return;
|
||||
const d = p.seriesData.get(candleSeries);
|
||||
if (!d) return;
|
||||
document.getElementById('ohlc-o').textContent = fmt(d.open, 2);
|
||||
document.getElementById('ohlc-h').textContent = fmt(d.high, 2);
|
||||
document.getElementById('ohlc-l').textContent = fmt(d.low, 2);
|
||||
document.getElementById('ohlc-c').textContent = fmt(d.close, 2);
|
||||
});
|
||||
|
||||
// Resize observer
|
||||
new ResizeObserver(() => {
|
||||
chart.applyOptions({ width: container.clientWidth, height: container.clientHeight });
|
||||
}).observe(container);
|
||||
|
||||
loadKlines();
|
||||
startKlineWs();
|
||||
}
|
||||
|
||||
async function loadKlines() {
|
||||
const interval = TF_MAP[bot.timeframe] || '5m';
|
||||
const baseUrl = 'https://api.binance.com/api/v3/klines';
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}?symbol=${bot.symbol}&interval=${interval}&limit=200`);
|
||||
const data = await res.json();
|
||||
const candles = data.map(k => ({
|
||||
time: Math.floor(k[0] / 1000),
|
||||
open: parseFloat(k[1]),
|
||||
high: parseFloat(k[2]),
|
||||
low: parseFloat(k[3]),
|
||||
close: parseFloat(k[4]),
|
||||
}));
|
||||
candleSeries.setData(candles);
|
||||
chart.timeScale().scrollToRealTime();
|
||||
chart.timeScale().setVisibleLogicalRange({ from: candles.length - 80, to: candles.length + 5 });
|
||||
|
||||
if (candles.length) {
|
||||
const last = candles[candles.length - 1];
|
||||
document.getElementById('ohlc-o').textContent = fmt(last.open, 2);
|
||||
document.getElementById('ohlc-h').textContent = fmt(last.high, 2);
|
||||
document.getElementById('ohlc-l').textContent = fmt(last.low, 2);
|
||||
document.getElementById('ohlc-c').textContent = fmt(last.close, 2);
|
||||
}
|
||||
updateChartOverlays();
|
||||
} catch(e) { console.error('loadKlines error:', e); }
|
||||
}
|
||||
|
||||
let klineWs = null;
|
||||
function startKlineWs() {
|
||||
if (klineWs) klineWs.close();
|
||||
const interval = TF_MAP[bot.timeframe] || '5m';
|
||||
const sym = bot.symbol.toLowerCase();
|
||||
const wsUrl = `wss://stream.binance.com:9443/ws/${sym}@kline_${interval}`;
|
||||
klineWs = new WebSocket(wsUrl);
|
||||
klineWs.onmessage = e => {
|
||||
const msg = JSON.parse(e.data);
|
||||
const k = msg.k;
|
||||
const candle = {
|
||||
time: Math.floor(k.t / 1000),
|
||||
open: parseFloat(k.o), high: parseFloat(k.h),
|
||||
low: parseFloat(k.l), close: parseFloat(k.c),
|
||||
};
|
||||
candleSeries.update(candle);
|
||||
document.getElementById('ohlc-o').textContent = fmt(candle.open, 2);
|
||||
document.getElementById('ohlc-h').textContent = fmt(candle.high, 2);
|
||||
document.getElementById('ohlc-l').textContent = fmt(candle.low, 2);
|
||||
document.getElementById('ohlc-c').textContent = fmt(candle.close, 2);
|
||||
// Fiyatı da güncelle
|
||||
document.getElementById('tb-price').textContent = fmt(candle.close, 2);
|
||||
};
|
||||
klineWs.onerror = () => {};
|
||||
klineWs.onclose = () => setTimeout(startKlineWs, 5000);
|
||||
}
|
||||
|
||||
// ── Chart overlays ────────────────────────────────
|
||||
function updateChartOverlays() {
|
||||
if (!candleSeries) return;
|
||||
|
||||
// Price lines temizle
|
||||
priceLines.forEach(pl => candleSeries.removePriceLine(pl));
|
||||
priceLines = [];
|
||||
|
||||
// Açık pozisyonlar → hedef price line (yeşil) + alış marker
|
||||
openPositionsCache.forEach(p => {
|
||||
priceLines.push(candleSeries.createPriceLine({
|
||||
price: p.sell_target,
|
||||
color: '#2ecc71',
|
||||
lineWidth: 1,
|
||||
lineStyle: LightweightCharts.LineStyle.Dashed,
|
||||
axisLabelVisible: true,
|
||||
title: fmt(p.sell_target, 2),
|
||||
}));
|
||||
});
|
||||
|
||||
// Markers: açık + kapalı pozisyonlar
|
||||
const buyMap = new Map();
|
||||
const sellMap = new Map();
|
||||
|
||||
openPositionsCache.forEach(p => {
|
||||
const t = buyTime(p.opened_at);
|
||||
if (!buyMap.has(t)) buyMap.set(t, []);
|
||||
buyMap.get(t).push(fmt(p.buy_price, 2));
|
||||
});
|
||||
|
||||
closedPositionsCache.forEach(c => {
|
||||
const tb = buyTime(c.opened_at);
|
||||
if (!buyMap.has(tb)) buyMap.set(tb, []);
|
||||
buyMap.get(tb).push(fmt(c.buy_price, 2));
|
||||
|
||||
if (c.status === 'FILLED') {
|
||||
const ts = Math.floor(c.closed_at / 1000);
|
||||
if (!sellMap.has(ts)) sellMap.set(ts, []);
|
||||
sellMap.get(ts).push(fmt(c.sell_target, 2));
|
||||
}
|
||||
});
|
||||
|
||||
const markers = [];
|
||||
buyMap.forEach((prices, time) => {
|
||||
markers.push({
|
||||
time,
|
||||
position: 'belowBar',
|
||||
color: '#6c63ff',
|
||||
shape: 'arrowUp',
|
||||
text: prices.length > 1 ? `×${prices.length}` : '',
|
||||
size: 1,
|
||||
});
|
||||
});
|
||||
sellMap.forEach((prices, time) => {
|
||||
markers.push({
|
||||
time,
|
||||
position: 'aboveBar',
|
||||
color: '#2ecc71',
|
||||
shape: 'arrowDown',
|
||||
text: prices.length > 1 ? `×${prices.length}` : '',
|
||||
size: 1,
|
||||
});
|
||||
});
|
||||
|
||||
markers.sort((a, b) => a.time - b.time);
|
||||
candleSeries.setMarkers(markers);
|
||||
}
|
||||
|
||||
// ── Pozisyon sekmeleri ────────────────────────────
|
||||
function switchTab(tab) {
|
||||
currentTab = tab;
|
||||
document.getElementById('tab-open').classList.toggle('active', tab === 'open');
|
||||
document.getElementById('tab-closed').classList.toggle('active', tab === 'closed');
|
||||
if (tab === 'open') loadPositions();
|
||||
else loadClosed();
|
||||
}
|
||||
|
||||
async function loadPositions() {
|
||||
const res = await api('GET', `/bots/${BOT_ID}/positions`);
|
||||
const data = await res.json();
|
||||
openPositionsCache = data;
|
||||
if (candleSeries) updateChartOverlays();
|
||||
const el = document.getElementById('left-content');
|
||||
if (!data.length) { el.innerHTML = '<div class="empty-row">Açık pozisyon yok</div>'; return; }
|
||||
el.innerHTML = `<table class="pos-table">
|
||||
<thead><tr><th>Alış</th><th>Hedef</th><th>Miktar</th><th>Zaman</th></tr></thead>
|
||||
<tbody>${data.map(p => `<tr>
|
||||
<td>${fmt(p.buy_price, 4)}</td>
|
||||
<td class="profit-up">${fmt(p.sell_target, 4)}</td>
|
||||
<td>${p.quantity}</td>
|
||||
<td style="font-size:10px;color:var(--muted)">${new Date(p.opened_at).toLocaleString('tr-TR',{hour:'2-digit',minute:'2-digit',day:'2-digit',month:'2-digit'})}</td>
|
||||
</tr>`).join('')}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
async function loadClosed() {
|
||||
const res = await api('GET', `/bots/${BOT_ID}/positions/closed`);
|
||||
const data = await res.json();
|
||||
closedPositionsCache = data;
|
||||
if (candleSeries) updateChartOverlays();
|
||||
const el = document.getElementById('left-content');
|
||||
if (!data.length) { el.innerHTML = '<div class="empty-row">Henüz kapalı işlem yok</div>'; return; }
|
||||
el.innerHTML = `<table class="pos-table">
|
||||
<thead><tr><th>Alış</th><th>Satış</th><th>Durum</th><th>Kapanış</th></tr></thead>
|
||||
<tbody>${data.map(c => `<tr>
|
||||
<td>${fmt(c.buy_price, 4)}</td>
|
||||
<td class="${c.status==='FILLED'?'profit-up':'profit-down'}">${fmt(c.sell_target, 4)}</td>
|
||||
<td><span class="badge ${c.status==='FILLED'?'badge-running':'badge-stopped'}" style="font-size:10px">${c.status}</span></td>
|
||||
<td style="font-size:10px;color:var(--muted)">${new Date(c.closed_at).toLocaleString('tr-TR',{hour:'2-digit',minute:'2-digit',day:'2-digit',month:'2-digit'})}</td>
|
||||
</tr>`).join('')}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
// ── Log ───────────────────────────────────────────
|
||||
async function loadInitialLogs() {
|
||||
const res = await api('GET', `/bots/${BOT_ID}/logs`);
|
||||
const logs = await res.json();
|
||||
logs.forEach(appendLog);
|
||||
if (logs.length) lastLogId = logs[logs.length - 1].id;
|
||||
}
|
||||
|
||||
function appendLog(log) {
|
||||
const terminal = document.getElementById('log-terminal');
|
||||
const time = new Date(log.created_at).toLocaleTimeString('tr-TR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
const div = document.createElement('div');
|
||||
div.className = 'log-line';
|
||||
div.innerHTML = `<span class="log-time">${time}</span><span class="log-level ${log.level}">${log.level.toUpperCase()}</span><span class="log-msg">${esc(log.message)}</span>`;
|
||||
terminal.appendChild(div);
|
||||
terminal.scrollTop = terminal.scrollHeight;
|
||||
}
|
||||
|
||||
function clearLog() { document.getElementById('log-terminal').innerHTML = ''; }
|
||||
|
||||
function connectLogSSE() {
|
||||
if (logSource) logSource.close();
|
||||
logSource = new EventSource(`/api/bots/${BOT_ID}/log-stream`, { withCredentials: true });
|
||||
logSource.addEventListener('log', e => {
|
||||
const log = JSON.parse(e.data);
|
||||
if (log.id <= lastLogId) return;
|
||||
lastLogId = log.id;
|
||||
appendLog(log);
|
||||
if (log.level === 'trade') {
|
||||
loadPositions();
|
||||
loadClosed();
|
||||
}
|
||||
});
|
||||
logSource.onopen = () => {
|
||||
document.getElementById('log-dot').style.background = 'var(--green)';
|
||||
document.getElementById('log-status').textContent = 'canlı';
|
||||
};
|
||||
logSource.onerror = () => {
|
||||
document.getElementById('log-dot').style.background = 'var(--muted)';
|
||||
document.getElementById('log-status').textContent = 'bağlantı kesildi';
|
||||
setTimeout(connectLogSSE, 5000);
|
||||
};
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────
|
||||
loadBot();
|
||||
loadPositions();
|
||||
loadClosed();
|
||||
loadInitialLogs().then(connectLogSSE);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -56,14 +56,6 @@
|
|||
.btn-primary:hover { background: #5a52d5; }
|
||||
.empty { padding: 24px 18px; color: var(--muted); font-size: 13px; text-align: center; }
|
||||
|
||||
/* Auth overlay */
|
||||
#auth-overlay { position: fixed; inset: 0; background: var(--bg); display: flex; align-items: center; justify-content: center; z-index: 200; }
|
||||
.auth-box { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 32px; width: 320px; display: flex; flex-direction: column; gap: 16px; }
|
||||
.auth-box h2 { font-size: 16px; color: var(--accent); }
|
||||
.auth-box input { background: var(--bg); border: 1px solid var(--border); border-radius: 5px; padding: 9px 12px; color: var(--text); font-size: 14px; width: 100%; }
|
||||
.auth-box input:focus { outline: none; border-color: var(--accent); }
|
||||
.auth-error { color: var(--red); font-size: 12px; display: none; }
|
||||
|
||||
/* Modal */
|
||||
#modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: none; align-items: center; justify-content: center; z-index: 100; }
|
||||
#modal-overlay.open { display: flex; }
|
||||
|
|
@ -102,14 +94,6 @@
|
|||
</head>
|
||||
<body>
|
||||
|
||||
<div id="auth-overlay">
|
||||
<div class="auth-box">
|
||||
<h2>Mukan Special Edition</h2>
|
||||
<input type="password" id="token-input" placeholder="Auth token" onkeydown="if(event.key==='Enter')login()" />
|
||||
<button class="btn btn-primary" onclick="login()">Giriş</button>
|
||||
<span class="auth-error" id="auth-error">Geçersiz token</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="modal-overlay" onclick="if(event.target===this)closeModal()">
|
||||
<div class="modal">
|
||||
|
|
@ -202,52 +186,26 @@
|
|||
</main>
|
||||
|
||||
<script>
|
||||
let AUTH_TOKEN = localStorage.getItem('mse_token') || '';
|
||||
let sseSource = null;
|
||||
let symbolSelect = null;
|
||||
let allSymbols = [];
|
||||
let currentMode = 'testnet';
|
||||
|
||||
function login() {
|
||||
const t = document.getElementById('token-input').value.trim();
|
||||
if (!t) return;
|
||||
AUTH_TOKEN = t;
|
||||
localStorage.setItem('mse_token', t);
|
||||
tryConnect();
|
||||
}
|
||||
|
||||
async function api(method, path, body) {
|
||||
const res = await fetch('/api' + path, {
|
||||
method,
|
||||
headers: { 'Authorization': 'Bearer ' + AUTH_TOKEN, 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (res.status === 401) { showAuthOverlay(); throw new Error('Unauthorized'); }
|
||||
if (res.status === 401) { window.location.href = '/'; throw new Error('Unauthorized'); }
|
||||
return res;
|
||||
}
|
||||
|
||||
function showAuthOverlay() {
|
||||
document.getElementById('auth-overlay').style.display = 'flex';
|
||||
}
|
||||
|
||||
function logout() {
|
||||
AUTH_TOKEN = '';
|
||||
localStorage.removeItem('mse_token');
|
||||
async function logout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
|
||||
if (sseSource) { sseSource.close(); sseSource = null; }
|
||||
document.getElementById('token-input').value = '';
|
||||
document.getElementById('auth-error').style.display = 'none';
|
||||
showAuthOverlay();
|
||||
}
|
||||
|
||||
async function tryConnect() {
|
||||
const res = await fetch('/api/bots', { headers: { 'Authorization': 'Bearer ' + AUTH_TOKEN } });
|
||||
if (res.status === 401) {
|
||||
document.getElementById('auth-error').style.display = 'block';
|
||||
return;
|
||||
}
|
||||
document.getElementById('auth-overlay').style.display = 'none';
|
||||
loadAll();
|
||||
connectSSE();
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
|
|
@ -335,7 +293,7 @@ async function loadBots() {
|
|||
if (!bots.length) { tbody.innerHTML = '<tr><td colspan="8" class="empty">Henüz bot yok</td></tr>'; return; }
|
||||
tbody.innerHTML = bots.map(b => `
|
||||
<tr>
|
||||
<td>${esc(b.name)}</td>
|
||||
<td><a href="/bots/${b.id}" style="color:inherit;text-decoration:underline;text-underline-offset:3px">${esc(b.name)}</a></td>
|
||||
<td><strong>${esc(b.symbol)}</strong></td>
|
||||
<td>${esc(b.timeframe)}</td>
|
||||
<td>${b.usdt_amount} USDT</td>
|
||||
|
|
@ -423,7 +381,7 @@ async function deleteBot(id, name) {
|
|||
|
||||
function connectSSE() {
|
||||
if (sseSource) sseSource.close();
|
||||
sseSource = new EventSource(`/api/events?token=${encodeURIComponent(AUTH_TOKEN)}`);
|
||||
sseSource = new EventSource('/api/events', { withCredentials: true });
|
||||
sseSource.addEventListener('trade', () => { loadPositions(); loadClosed(); });
|
||||
sseSource.onopen = () => {
|
||||
document.getElementById('sse-dot').className = 'status-dot connected';
|
||||
|
|
@ -440,8 +398,8 @@ function esc(s) {
|
|||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
if (AUTH_TOKEN) tryConnect();
|
||||
else showAuthOverlay();
|
||||
loadAll();
|
||||
connectSSE();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
70
src/web/login.html
Normal file
70
src/web/login.html
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Reference in a new issue