fix: lot size floor, mod geçişi, PnL kolonu, iptal butonu, sayfalama, grafik okları
- LOT_SIZE: step_size floor + minQty kontrolü (DOGE gibi tam sayı coinler artık çalışır) - Mod geçişi: _header.html'e setMode() + applyModeUI() eklendi, her sayfada çalışır - Açık pozisyonlar: anlık PnL % kolonu + Binance'te iptal butonu - Kapalı işlemler: 20'li sayfalama - Grafik okları: chart zaman aralığı dışındaki markerlar filtrelendi Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
15b619efa0
commit
d1085cc4cc
7 changed files with 267 additions and 40 deletions
|
|
@ -249,3 +249,59 @@ pub async fn get_bot_closed(
|
|||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn cancel_position(
|
||||
State(state): State<AppState>,
|
||||
Path((bot_id, order_id)): Path<(String, u64)>,
|
||||
) -> impl IntoResponse {
|
||||
let db = state.db.lock().await;
|
||||
let positions = match db.get_positions_by_bot(&bot_id) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
};
|
||||
let pos = match positions.into_iter().find(|p| p.order_id == order_id) {
|
||||
Some(p) => p,
|
||||
None => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
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 == bot_id) {
|
||||
Some(c) => c,
|
||||
None => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
drop(db);
|
||||
|
||||
let mode = state.current_mode.lock().await.clone();
|
||||
let (api_key, api_secret) = if mode == "live" {
|
||||
(state.live_api_key.clone(), state.live_api_secret.clone())
|
||||
} else {
|
||||
(state.testnet_api_key.clone(), state.testnet_api_secret.clone())
|
||||
};
|
||||
let client = BinanceClient::new(api_key, api_secret, config.testnet);
|
||||
|
||||
if let Err(e) = client.cancel_order(&pos.symbol, order_id).await {
|
||||
return (StatusCode::BAD_GATEWAY, e.to_string()).into_response();
|
||||
}
|
||||
|
||||
let db = state.db.lock().await;
|
||||
let closed = crate::storage::closed::ClosedPosition {
|
||||
bot_id: pos.bot_id.clone(),
|
||||
bot_name: pos.bot_name.clone(),
|
||||
symbol: pos.symbol.clone(),
|
||||
order_id: pos.order_id,
|
||||
buy_price: pos.buy_price,
|
||||
sell_target: pos.sell_target,
|
||||
quantity: pos.quantity,
|
||||
profit_percent: pos.profit_percent,
|
||||
opened_at: pos.opened_at,
|
||||
closed_at: chrono::Utc::now().timestamp_millis(),
|
||||
status: "CANCELED".to_string(),
|
||||
};
|
||||
db.insert_closed(&closed).ok();
|
||||
db.remove_position(order_id).ok();
|
||||
db.insert_log(&bot_id, "info", &format!("Pozisyon manuel iptal edildi | Order #{}", order_id)).ok();
|
||||
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ pub fn build(state: AppState) -> Router {
|
|||
.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("/bots/:id/positions/:order_id/cancel", post(bots::cancel_position))
|
||||
.route("/positions", get(positions::open_positions))
|
||||
.route("/positions/closed", get(positions::closed_positions))
|
||||
.route("/mode", get(mode::get_mode).post(mode::set_mode))
|
||||
|
|
|
|||
|
|
@ -136,6 +136,42 @@ impl BinanceClient {
|
|||
Ok(response["status"].as_str().unwrap_or("UNKNOWN").to_string())
|
||||
}
|
||||
|
||||
/// Açık bir limit emrini iptal eder
|
||||
pub async fn cancel_order(&self, symbol: &str, order_id: u64) -> Result<()> {
|
||||
let timestamp = chrono::Utc::now().timestamp_millis().to_string();
|
||||
let order_id_str = order_id.to_string();
|
||||
let params = format!(
|
||||
"symbol={}&orderId={}×tamp={}",
|
||||
symbol, order_id_str, timestamp
|
||||
);
|
||||
let signature = self.sign(¶ms);
|
||||
let url = format!("{}/api/v3/order", self.base_url);
|
||||
|
||||
let response = self
|
||||
.http
|
||||
.delete(&url)
|
||||
.header("X-MBX-APIKEY", &self.api_key)
|
||||
.query(&[
|
||||
("symbol", symbol),
|
||||
("orderId", order_id_str.as_str()),
|
||||
("timestamp", timestamp.as_str()),
|
||||
("signature", signature.as_str()),
|
||||
])
|
||||
.send()
|
||||
.await?
|
||||
.json::<Value>()
|
||||
.await?;
|
||||
|
||||
if let Some(code) = response.get("code") {
|
||||
return Err(anyhow!(
|
||||
"Binance iptal hatası {}: {}",
|
||||
code,
|
||||
response["msg"].as_str().unwrap_or("")
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sembolün lot size ve tick size filtrelerini getirir
|
||||
pub async fn get_symbol_filters(&self, symbol: &str) -> Result<SymbolFilters> {
|
||||
let url = format!("{}/api/v3/exchangeInfo", self.base_url);
|
||||
|
|
@ -156,12 +192,16 @@ impl BinanceClient {
|
|||
|
||||
let mut qty_decimals = 2usize;
|
||||
let mut price_decimals = 2usize;
|
||||
let mut step_size = 0.01f64;
|
||||
let mut min_qty = 0.0f64;
|
||||
|
||||
for filter in filters {
|
||||
match filter["filterType"].as_str() {
|
||||
Some("LOT_SIZE") => {
|
||||
let step = filter["stepSize"].as_str().unwrap_or("0.01");
|
||||
qty_decimals = count_decimals(step);
|
||||
step_size = step.parse().unwrap_or(0.01);
|
||||
min_qty = filter["minQty"].as_str().unwrap_or("0").parse().unwrap_or(0.0);
|
||||
}
|
||||
Some("PRICE_FILTER") => {
|
||||
let tick = filter["tickSize"].as_str().unwrap_or("0.01");
|
||||
|
|
@ -171,7 +211,7 @@ impl BinanceClient {
|
|||
}
|
||||
}
|
||||
|
||||
Ok(SymbolFilters { qty_decimals, price_decimals })
|
||||
Ok(SymbolFilters { qty_decimals, price_decimals, step_size, min_qty })
|
||||
}
|
||||
|
||||
/// Tüm USDT spot işlem çiftlerini döner — public endpoint, API key gerektirmez
|
||||
|
|
|
|||
|
|
@ -50,15 +50,26 @@ pub enum OrderSide {
|
|||
pub struct SymbolFilters {
|
||||
pub qty_decimals: usize,
|
||||
pub price_decimals: usize,
|
||||
pub step_size: f64,
|
||||
pub min_qty: f64,
|
||||
}
|
||||
|
||||
impl SymbolFilters {
|
||||
/// Miktarı step_size'a göre aşağı yuvarlar (floor), sonra formatlar.
|
||||
pub fn format_qty(&self, qty: f64) -> String {
|
||||
format!("{:.prec$}", qty, prec = self.qty_decimals)
|
||||
let floored = if self.step_size > 0.0 {
|
||||
(qty / self.step_size).floor() * self.step_size
|
||||
} else {
|
||||
qty
|
||||
};
|
||||
format!("{:.prec$}", floored, prec = self.qty_decimals)
|
||||
}
|
||||
pub fn format_price(&self, price: f64) -> String {
|
||||
format!("{:.prec$}", price, prec = self.price_decimals)
|
||||
}
|
||||
pub fn qty_is_valid(&self, qty: f64) -> bool {
|
||||
qty >= self.min_qty
|
||||
}
|
||||
}
|
||||
|
||||
/// Timeframe tanımları
|
||||
|
|
|
|||
|
|
@ -31,6 +31,13 @@ impl RedCandleStrategy {
|
|||
let filters = client.get_symbol_filters(symbol).await?;
|
||||
let raw_qty = usdt_amount / kline.close;
|
||||
let quantity = filters.format_qty(raw_qty);
|
||||
let qty_f: f64 = quantity.parse().unwrap_or(0.0);
|
||||
if !filters.qty_is_valid(qty_f) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Miktar ({}) min lot size ({}) altında — USDT miktarını artır",
|
||||
quantity, filters.min_qty
|
||||
));
|
||||
}
|
||||
|
||||
info!("[{}] Market alım | Miktar: {} | Fiyat tahmini: {:.6}", symbol, quantity, kline.close);
|
||||
|
||||
|
|
|
|||
|
|
@ -8,8 +8,42 @@
|
|||
</nav>
|
||||
<div class="spacer"></div>
|
||||
<div class="mode-toggle">
|
||||
<button class="mode-btn" id="btn-live">Canlı</button>
|
||||
<button class="mode-btn" id="btn-testnet">Testnet</button>
|
||||
<button class="mode-btn" id="btn-live" onclick="setMode('live')">Canlı</button>
|
||||
<button class="mode-btn" id="btn-testnet" onclick="setMode('testnet')">Testnet</button>
|
||||
</div>
|
||||
<button class="btn btn-logout" onclick="logout()">Çıkış</button>
|
||||
</header>
|
||||
<script>
|
||||
(function() {
|
||||
async function initMode() {
|
||||
try {
|
||||
const res = await fetch('/api/mode', { credentials: 'include' });
|
||||
if (!res.ok) return;
|
||||
const { mode } = await res.json();
|
||||
applyModeUI(mode);
|
||||
} catch(e) {}
|
||||
}
|
||||
window.setMode = async function(mode) {
|
||||
try {
|
||||
const res = await fetch('/api/mode', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode }),
|
||||
});
|
||||
if (!res.ok) { alert('Mod değiştirilemedi'); return; }
|
||||
applyModeUI(mode);
|
||||
if (typeof onModeChange === 'function') onModeChange(mode);
|
||||
else window.location.reload();
|
||||
} catch(e) { alert('Bağlantı hatası'); }
|
||||
};
|
||||
function applyModeUI(mode) {
|
||||
const live = document.getElementById('btn-live');
|
||||
const test = document.getElementById('btn-testnet');
|
||||
if (!live || !test) return;
|
||||
live.className = 'mode-btn' + (mode === 'live' ? ' active-live' : '');
|
||||
test.className = 'mode-btn' + (mode === 'testnet' ? ' active-testnet' : '');
|
||||
}
|
||||
initMode();
|
||||
})();
|
||||
</script>
|
||||
|
|
|
|||
150
src/web/bot.html
150
src/web/bot.html
|
|
@ -157,6 +157,17 @@
|
|||
.stat-row { display: flex; justify-content: space-between; align-items: center; }
|
||||
.stat-row .lbl { font-size: 11px; color: var(--muted); }
|
||||
.stat-row .val { font-size: 12px; font-weight: 600; }
|
||||
|
||||
/* pagination */
|
||||
.pagination { display: flex; align-items: center; justify-content: center; gap: 4px; padding: 4px 0; border-top: 1px solid var(--border); }
|
||||
.pg-btn { background: none; border: 1px solid var(--border); color: var(--muted); border-radius: 3px; padding: 1px 7px; font-size: 10px; cursor: pointer; }
|
||||
.pg-btn:hover { color: var(--text); border-color: var(--accent); }
|
||||
.pg-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.pg-info { font-size: 10px; color: var(--muted); }
|
||||
|
||||
/* cancel btn in table */
|
||||
.btn-cancel { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 12px; padding: 0 2px; line-height: 1; }
|
||||
.btn-cancel:hover { color: var(--red); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -200,8 +211,8 @@
|
|||
</div>
|
||||
<div class="pos-scroll">
|
||||
<table class="pos-table">
|
||||
<thead><tr><th>Zaman</th><th>Alış</th><th>Hedef</th><th>Adet</th></tr></thead>
|
||||
<tbody id="open-body"><tr><td colspan="4"><div class="empty-row">—</div></td></tr></tbody>
|
||||
<thead><tr><th>Zaman</th><th>Alış</th><th>Hedef</th><th>PnL</th><th></th></tr></thead>
|
||||
<tbody id="open-body"><tr><td colspan="5"><div class="empty-row">—</div></td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -217,6 +228,7 @@
|
|||
<tbody id="closed-body"><tr><td colspan="4"><div class="empty-row">—</div></td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="closed-pagination"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
|
@ -298,6 +310,9 @@ let priceWs = null;
|
|||
let priceLines = [];
|
||||
let openPositionsCache = [];
|
||||
let closedPositionsCache = [];
|
||||
let closedPage = 0;
|
||||
const CLOSED_PAGE_SIZE = 20;
|
||||
let currentPrice = 0;
|
||||
|
||||
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 };
|
||||
|
|
@ -322,20 +337,17 @@ function buyTime(ms) {
|
|||
return cur - tf;
|
||||
}
|
||||
|
||||
// ── Mode change hook (header'dan çağrılır) ────────
|
||||
function onModeChange(_mode) {
|
||||
loadPositions();
|
||||
loadClosed();
|
||||
}
|
||||
|
||||
// ── Load ──────────────────────────────────────────
|
||||
async function loadBot() {
|
||||
const [botRes, modeRes] = await Promise.all([
|
||||
api('GET', `/bots/${BOT_ID}`),
|
||||
api('GET', '/mode'),
|
||||
]);
|
||||
if (!botRes.ok) { window.location.href = '/dashboard'; return; }
|
||||
bot = await botRes.json();
|
||||
|
||||
if (modeRes.ok) {
|
||||
const { mode } = await modeRes.json();
|
||||
document.getElementById('btn-live').className = 'mode-btn' + (mode === 'live' ? ' active-live' : '');
|
||||
document.getElementById('btn-testnet').className = 'mode-btn' + (mode === 'testnet' ? ' active-testnet' : '');
|
||||
}
|
||||
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`;
|
||||
renderPriceBar();
|
||||
|
|
@ -445,11 +457,13 @@ function startPriceWs() {
|
|||
priceWs.onmessage = e => {
|
||||
const t = JSON.parse(e.data);
|
||||
const price = parseFloat(t.c);
|
||||
currentPrice = price;
|
||||
const change = parseFloat(t.P);
|
||||
document.getElementById('pb-price').textContent = fmt(price, 2);
|
||||
const chEl = document.getElementById('pb-change');
|
||||
chEl.textContent = (change >= 0 ? '+' : '') + change.toFixed(2) + '%';
|
||||
chEl.style.color = change >= 0 ? 'var(--green)' : 'var(--red)';
|
||||
updateOpenPnl();
|
||||
};
|
||||
priceWs.onerror = () => {};
|
||||
priceWs.onclose = () => setTimeout(startPriceWs, 5000);
|
||||
|
|
@ -540,20 +554,34 @@ function updateChartOverlays() {
|
|||
axisLabelVisible: true, title: fmt(p.sell_target, 2),
|
||||
}));
|
||||
});
|
||||
|
||||
// Chart'taki mum zaman aralığını al
|
||||
const tsData = candleSeries.data ? candleSeries.data() : null;
|
||||
let minTime = 0, maxTime = Infinity;
|
||||
if (tsData && tsData.length) {
|
||||
minTime = tsData[0].time;
|
||||
maxTime = tsData[tsData.length - 1].time;
|
||||
}
|
||||
|
||||
const buyMap = new Map(), sellMap = new Map();
|
||||
openPositionsCache.forEach(p => {
|
||||
const t = buyTime(p.opened_at);
|
||||
if (t < minTime || t > maxTime) return;
|
||||
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 (tb >= minTime && tb <= maxTime) {
|
||||
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));
|
||||
if (ts >= minTime && ts <= maxTime) {
|
||||
if (!sellMap.has(ts)) sellMap.set(ts, []);
|
||||
sellMap.get(ts).push(fmt(c.sell_target, 2));
|
||||
}
|
||||
}
|
||||
});
|
||||
const markers = [];
|
||||
|
|
@ -569,39 +597,89 @@ async function loadPositions() {
|
|||
openPositionsCache = await res.json();
|
||||
if (candleSeries) updateChartOverlays();
|
||||
document.getElementById('open-count').textContent = openPositionsCache.length;
|
||||
renderOpenTable();
|
||||
renderStats();
|
||||
}
|
||||
|
||||
function renderOpenTable() {
|
||||
const tbody = document.getElementById('open-body');
|
||||
if (!openPositionsCache.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="4"><div class="empty-row">Açık pozisyon yok</div></td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = openPositionsCache.map(p => `<tr>
|
||||
tbody.innerHTML = '<tr><td colspan="5"><div class="empty-row">Açık pozisyon yok</div></td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = openPositionsCache.map(p => {
|
||||
const pnl = currentPrice > 0
|
||||
? ((currentPrice - p.buy_price) / p.buy_price * 100)
|
||||
: null;
|
||||
const pnlHtml = pnl !== null
|
||||
? `<span class="${pnl >= 0 ? 'c-green' : 'c-red'}">${pnl >= 0 ? '+' : ''}${pnl.toFixed(2)}%</span>`
|
||||
: '<span class="c-muted">—</span>';
|
||||
return `<tr>
|
||||
<td>${shortDate(p.opened_at)}</td>
|
||||
<td>${fmt(p.buy_price, 4)}</td>
|
||||
<td class="c-green">${fmt(p.sell_target, 4)}</td>
|
||||
<td class="c-muted">${parseFloat(p.quantity).toFixed(4)}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
renderStats();
|
||||
<td>${pnlHtml}</td>
|
||||
<td><button class="btn-cancel" title="İptal" onclick="cancelPosition(${p.order_id})">✕</button></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function updateOpenPnl() {
|
||||
if (!openPositionsCache.length) return;
|
||||
renderOpenTable();
|
||||
}
|
||||
|
||||
async function cancelPosition(orderId) {
|
||||
if (!confirm('Bu pozisyonu Binance\'te iptal et?')) return;
|
||||
const res = await api('POST', `/bots/${BOT_ID}/positions/${orderId}/cancel`);
|
||||
if (res.ok) { loadPositions(); loadClosed(); }
|
||||
else { alert('İptal başarısız: ' + (await res.text())); }
|
||||
}
|
||||
|
||||
async function loadClosed() {
|
||||
const res = await api('GET', `/bots/${BOT_ID}/positions/closed`);
|
||||
closedPositionsCache = await res.json();
|
||||
closedPage = 0;
|
||||
if (candleSeries) updateChartOverlays();
|
||||
document.getElementById('closed-count').textContent = closedPositionsCache.length;
|
||||
const tbody = document.getElementById('closed-body');
|
||||
if (!closedPositionsCache.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="4"><div class="empty-row">Henüz işlem yok</div></td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = closedPositionsCache.map(c => `<tr>
|
||||
<td>${shortDate(c.opened_at)}</td>
|
||||
<td>${fmt(c.buy_price, 4)}</td>
|
||||
<td>${fmt(c.sell_target, 4)}</td>
|
||||
<td class="${c.status === 'FILLED' ? 'c-green' : 'c-muted'}" style="font-size:10px;font-weight:600">${c.status}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
renderClosedTable();
|
||||
renderStats();
|
||||
}
|
||||
|
||||
function renderClosedTable() {
|
||||
const tbody = document.getElementById('closed-body');
|
||||
const total = closedPositionsCache.length;
|
||||
if (!total) {
|
||||
tbody.innerHTML = '<tr><td colspan="4"><div class="empty-row">Henüz işlem yok</div></td></tr>';
|
||||
document.getElementById('closed-pagination').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const totalPages = Math.ceil(total / CLOSED_PAGE_SIZE);
|
||||
if (closedPage >= totalPages) closedPage = totalPages - 1;
|
||||
const start = closedPage * CLOSED_PAGE_SIZE;
|
||||
const page = closedPositionsCache.slice(start, start + CLOSED_PAGE_SIZE);
|
||||
tbody.innerHTML = page.map(c => `<tr>
|
||||
<td>${shortDate(c.opened_at)}</td>
|
||||
<td>${fmt(c.buy_price, 4)}</td>
|
||||
<td>${fmt(c.sell_target, 4)}</td>
|
||||
<td class="${c.status === 'FILLED' ? 'c-green' : 'c-muted'}" style="font-size:10px;font-weight:600">${c.status}</td>
|
||||
</tr>`).join('');
|
||||
|
||||
const pg = document.getElementById('closed-pagination');
|
||||
if (totalPages <= 1) { pg.innerHTML = ''; return; }
|
||||
let btns = '';
|
||||
if (closedPage > 0) btns += `<button class="pg-btn" onclick="goClosedPage(${closedPage-1})">‹</button>`;
|
||||
const lo = Math.max(0, closedPage - 2), hi = Math.min(totalPages - 1, closedPage + 2);
|
||||
for (let i = lo; i <= hi; i++) {
|
||||
btns += `<button class="pg-btn${i===closedPage?' active':''}" onclick="goClosedPage(${i})">${i+1}</button>`;
|
||||
}
|
||||
if (closedPage < totalPages - 1) btns += `<button class="pg-btn" onclick="goClosedPage(${closedPage+1})">›</button>`;
|
||||
btns += `<span class="pg-info">${start+1}-${Math.min(start+CLOSED_PAGE_SIZE,total)} / ${total}</span>`;
|
||||
pg.innerHTML = btns;
|
||||
}
|
||||
|
||||
function goClosedPage(p) { closedPage = p; renderClosedTable(); }
|
||||
|
||||
// ── Log ───────────────────────────────────────────
|
||||
async function loadInitialLogs() {
|
||||
const res = await api('GET', `/bots/${BOT_ID}/logs`);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue