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:
Mukan Erkin TÖRÜK 2026-04-19 12:22:00 +03:00
parent e2e5b2e6c6
commit 3a54d7f437
16 changed files with 1354 additions and 85 deletions

View file

@ -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
View file

@ -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"

View file

@ -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"

View file

@ -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),
}
}

View file

@ -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(),
}
}

View file

@ -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())
}

View file

@ -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()
}
}

View file

@ -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();
}
}
}

View file

@ -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()),
}

View file

@ -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,

View file

@ -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
View 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,
}

View file

@ -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
View 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')">ı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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
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">ı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>

View file

@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
if (AUTH_TOKEN) tryConnect();
else showAuthOverlay();
loadAll();
connectSSE();
</script>
</body>
</html>

70
src/web/login.html Normal file

File diff suppressed because one or more lines are too long