mirror of
https://github.com/trailbaseio/trailbase.git
synced 2026-04-26 10:38:45 -05:00
Merge branch 'leaflet'
This commit is contained in:
Generated
+22
@@ -1785,6 +1785,15 @@ version = "2.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708"
|
||||
|
||||
[[package]]
|
||||
name = "ipnetwork"
|
||||
version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-terminal"
|
||||
version = "0.4.13"
|
||||
@@ -2106,6 +2115,18 @@ version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||
|
||||
[[package]]
|
||||
name = "maxminddb"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6087e5d8ea14861bb7c7f573afbc7be3798d3ef0fae87ec4fd9a4de9a127c3c"
|
||||
dependencies = [
|
||||
"ipnetwork",
|
||||
"log",
|
||||
"memchr",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.7.4"
|
||||
@@ -3994,6 +4015,7 @@ dependencies = [
|
||||
"jsonschema",
|
||||
"libsql",
|
||||
"lru",
|
||||
"maxminddb",
|
||||
"parking_lot",
|
||||
"rand",
|
||||
"regex",
|
||||
|
||||
Generated
+51
@@ -267,6 +267,15 @@ importers:
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
geojson:
|
||||
specifier: ^0.5.0
|
||||
version: 0.5.0
|
||||
i18n-iso-countries:
|
||||
specifier: ^7.12.0
|
||||
version: 7.12.0
|
||||
leaflet:
|
||||
specifier: ^1.9.4
|
||||
version: 1.9.4
|
||||
long:
|
||||
specifier: ^5.2.3
|
||||
version: 5.2.3
|
||||
@@ -307,6 +316,12 @@ importers:
|
||||
'@tailwindcss/typography':
|
||||
specifier: ^0.5.15
|
||||
version: 0.5.15(tailwindcss@3.4.14(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3)))
|
||||
'@types/geojson':
|
||||
specifier: ^7946.0.14
|
||||
version: 7946.0.14
|
||||
'@types/leaflet':
|
||||
specifier: ^1.9.14
|
||||
version: 1.9.14
|
||||
'@types/wicg-file-system-access':
|
||||
specifier: ^2023.10.5
|
||||
version: 2023.10.5
|
||||
@@ -1481,12 +1496,18 @@ packages:
|
||||
'@types/estree@1.0.6':
|
||||
resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==}
|
||||
|
||||
'@types/geojson@7946.0.14':
|
||||
resolution: {integrity: sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==}
|
||||
|
||||
'@types/hast@3.0.4':
|
||||
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
|
||||
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
'@types/leaflet@1.9.14':
|
||||
resolution: {integrity: sha512-sx2q6MDJaajwhKeVgPSvqXd8rhNJSTA3tMidQGduZn9S6WBYxDkCpSpV5xXEmSg7Cgdk/5vJGhVF1kMYLzauBg==}
|
||||
|
||||
'@types/mdast@4.0.4':
|
||||
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
|
||||
|
||||
@@ -2107,6 +2128,9 @@ packages:
|
||||
devlop@1.1.0:
|
||||
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
||||
|
||||
diacritics@1.3.0:
|
||||
resolution: {integrity: sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA==}
|
||||
|
||||
didyoumean@1.2.2:
|
||||
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
||||
|
||||
@@ -2411,6 +2435,10 @@ packages:
|
||||
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
geojson@0.5.0:
|
||||
resolution: {integrity: sha512-/Bx5lEn+qRF4TfQ5aLu6NH+UKtvIv7Lhc487y/c8BdludrCTpiWf9wyI0RTyqg49MFefIAvFDuEi5Dfd/zgNxQ==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
get-caller-file@2.0.5:
|
||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||
engines: {node: 6.* || 8.* || >= 10.*}
|
||||
@@ -2587,6 +2615,10 @@ packages:
|
||||
resolution: {integrity: sha512-/1/GPCpDUCCYwlERiYjxoczfP0zfvZMU/OWgQPMya9AbAE24vseigFdhAMObpc8Q4lc/kjutPfUddDYyAmejnA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
|
||||
i18n-iso-countries@7.12.0:
|
||||
resolution: {integrity: sha512-NDFf5j/raA5JrcPT/NcHP3RUMH7TkdkxQKAKdvDlgb+MS296WJzzqvV0Y5uwavSm7A6oYvBeSV0AxoHdDiHIiw==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
i18next@23.16.4:
|
||||
resolution: {integrity: sha512-9NIYBVy9cs4wIqzurf7nLXPyf3R78xYbxExVqHLK9od3038rjpyOEzW+XB130kZ1N4PZ9inTtJ471CRJ4Ituyg==}
|
||||
|
||||
@@ -2802,6 +2834,9 @@ packages:
|
||||
kolorist@1.8.0:
|
||||
resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
|
||||
|
||||
leaflet@1.9.4:
|
||||
resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
|
||||
|
||||
levn@0.4.1:
|
||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -5708,12 +5743,18 @@ snapshots:
|
||||
|
||||
'@types/estree@1.0.6': {}
|
||||
|
||||
'@types/geojson@7946.0.14': {}
|
||||
|
||||
'@types/hast@3.0.4':
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/leaflet@1.9.14':
|
||||
dependencies:
|
||||
'@types/geojson': 7946.0.14
|
||||
|
||||
'@types/mdast@4.0.4':
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
@@ -6539,6 +6580,8 @@ snapshots:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
diacritics@1.3.0: {}
|
||||
|
||||
didyoumean@1.2.2: {}
|
||||
|
||||
diff@4.0.2: {}
|
||||
@@ -6895,6 +6938,8 @@ snapshots:
|
||||
|
||||
gensync@1.0.0-beta.2: {}
|
||||
|
||||
geojson@0.5.0: {}
|
||||
|
||||
get-caller-file@2.0.5: {}
|
||||
|
||||
get-east-asian-width@1.3.0: {}
|
||||
@@ -7208,6 +7253,10 @@ snapshots:
|
||||
|
||||
human-signals@8.0.0: {}
|
||||
|
||||
i18n-iso-countries@7.12.0:
|
||||
dependencies:
|
||||
diacritics: 1.3.0
|
||||
|
||||
i18next@23.16.4:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.26.0
|
||||
@@ -7385,6 +7434,8 @@ snapshots:
|
||||
|
||||
kolorist@1.8.0: {}
|
||||
|
||||
leaflet@1.9.4: {}
|
||||
|
||||
levn@0.4.1:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type LogJson = { id: string, created: number, type: number, level: number, status: number, method: string, url: string, latency_ms: number, client_ip: string, referer: string, user_agent: string, data: Object | undefined, };
|
||||
export type LogJson = { id: string, created: number, type: number, level: number, status: number, method: string, url: string, latency_ms: number, client_ip: string, client_cc: string | null, referer: string, user_agent: string, data: Object | undefined, };
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type Stats = { rate: Array<[bigint, number]>, };
|
||||
export type Stats = { rate: Array<[bigint, number]>, country_codes: { [key in string]?: number } | null, };
|
||||
|
||||
@@ -7,6 +7,7 @@ use lazy_static::lazy_static;
|
||||
use libsql::{de, params::Params, Connection};
|
||||
use log::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use trailbase_sqlite::query_one_row;
|
||||
use ts_rs::TS;
|
||||
use uuid::Uuid;
|
||||
@@ -17,7 +18,6 @@ use crate::constants::{LOGS_RETENTION_DEFAULT, LOGS_TABLE_ID_COLUMN};
|
||||
use crate::listing::{
|
||||
build_filter_where_clause, limit_or_default, parse_query, Order, WhereClause,
|
||||
};
|
||||
use crate::logging::Log;
|
||||
use crate::table_metadata::{lookup_and_parse_table_schema, TableMetadata};
|
||||
use crate::util::id_to_b64;
|
||||
|
||||
@@ -35,6 +35,7 @@ pub struct LogJson {
|
||||
|
||||
pub latency_ms: f64,
|
||||
pub client_ip: String,
|
||||
pub client_cc: Option<String>,
|
||||
pub referer: String,
|
||||
pub user_agent: String,
|
||||
|
||||
@@ -42,8 +43,30 @@ pub struct LogJson {
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl From<Log> for LogJson {
|
||||
fn from(value: Log) -> Self {
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct LogQuery {
|
||||
id: Option<[u8; 16]>,
|
||||
created: Option<f64>,
|
||||
r#type: i32,
|
||||
|
||||
level: i32,
|
||||
status: u16,
|
||||
method: String,
|
||||
url: String,
|
||||
|
||||
// milliseconds
|
||||
latency: f64,
|
||||
client_ip: String,
|
||||
client_cc: Option<String>,
|
||||
|
||||
referer: String,
|
||||
user_agent: String,
|
||||
|
||||
data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl From<LogQuery> for LogJson {
|
||||
fn from(value: LogQuery) -> Self {
|
||||
return LogJson {
|
||||
id: Uuid::from_bytes(value.id.unwrap()),
|
||||
created: value.created.unwrap_or(0.0),
|
||||
@@ -54,6 +77,7 @@ impl From<Log> for LogJson {
|
||||
url: value.url,
|
||||
latency_ms: value.latency,
|
||||
client_ip: value.client_ip,
|
||||
client_cc: value.client_cc,
|
||||
referer: value.referer,
|
||||
user_agent: value.user_agent,
|
||||
data: value.data,
|
||||
@@ -71,7 +95,6 @@ pub struct ListLogsResponse {
|
||||
stats: Option<Stats>,
|
||||
}
|
||||
|
||||
// FIXME: should be an admin-only api.
|
||||
pub async fn list_logs_handler(
|
||||
State(state): State<AppState>,
|
||||
RawQuery(raw_url_query): RawQuery,
|
||||
@@ -166,21 +189,21 @@ async fn fetch_logs(
|
||||
cursor: Option<[u8; 16]>,
|
||||
order: Vec<(String, Order)>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<Log>, Error> {
|
||||
) -> Result<Vec<LogQuery>, Error> {
|
||||
let mut params = filter_where_clause.params;
|
||||
let mut where_clause = filter_where_clause.clause;
|
||||
params.push((":limit".to_string(), libsql::Value::Integer(limit as i64)));
|
||||
|
||||
if let Some(cursor) = cursor {
|
||||
params.push((":cursor".to_string(), libsql::Value::Blob(cursor.to_vec())));
|
||||
where_clause = format!("{where_clause} AND _row_.id < :cursor",);
|
||||
where_clause = format!("{where_clause} AND log.id < :cursor",);
|
||||
}
|
||||
|
||||
let order_clause = order
|
||||
.iter()
|
||||
.map(|(col, ord)| {
|
||||
format!(
|
||||
"_row_.{col} {}",
|
||||
"log.{col} {}",
|
||||
match ord {
|
||||
Order::Descending => "DESC",
|
||||
Order::Ascending => "ASC",
|
||||
@@ -192,9 +215,9 @@ async fn fetch_logs(
|
||||
|
||||
let sql_query = format!(
|
||||
r#"
|
||||
SELECT _row_.*
|
||||
SELECT log.*, geoip_country(log.client_ip) AS client_cc
|
||||
FROM
|
||||
(SELECT * FROM {LOGS_TABLE_NAME}) as _row_
|
||||
(SELECT * FROM {LOGS_TABLE_NAME}) AS log
|
||||
WHERE
|
||||
{where_clause}
|
||||
ORDER BY
|
||||
@@ -205,13 +228,14 @@ async fn fetch_logs(
|
||||
|
||||
let mut rows = conn.query(&sql_query, Params::Named(params)).await?;
|
||||
|
||||
let mut logs: Vec<Log> = vec![];
|
||||
let mut logs: Vec<LogQuery> = vec![];
|
||||
while let Ok(Some(row)) = rows.next().await {
|
||||
match de::from_row(&row) {
|
||||
Ok(log) => logs.push(log),
|
||||
Err(err) => warn!("failed: {err}"),
|
||||
};
|
||||
}
|
||||
|
||||
return Ok(logs);
|
||||
}
|
||||
|
||||
@@ -219,6 +243,8 @@ async fn fetch_logs(
|
||||
pub struct Stats {
|
||||
// List of (timestamp, value).
|
||||
rate: Vec<(i64, f64)>,
|
||||
// Country codes.
|
||||
country_codes: Option<HashMap<String, usize>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -299,7 +325,42 @@ async fn fetch_aggregate_stats(
|
||||
));
|
||||
}
|
||||
|
||||
return Ok(Stats { rate });
|
||||
if trailbase_sqlite::has_geoip_db() {
|
||||
let cc_query = format!(
|
||||
r#"
|
||||
SELECT
|
||||
country_code,
|
||||
SUM(cnt) as count
|
||||
FROM
|
||||
(SELECT client_ip, COUNT(*) AS cnt, geoip_country(client_ip) as country_code FROM {LOGS_TABLE_NAME} GROUP BY client_ip)
|
||||
GROUP BY
|
||||
country_code
|
||||
"#
|
||||
);
|
||||
|
||||
let mut rows = conn.query(&cc_query, ()).await?;
|
||||
|
||||
let mut country_codes = HashMap::<String, usize>::new();
|
||||
while let Ok(Some(row)) = rows.next().await {
|
||||
let cc: Option<String> = row.get(0)?;
|
||||
let count: i64 = row.get(1)?;
|
||||
|
||||
country_codes.insert(
|
||||
cc.unwrap_or_else(|| "unattributed".to_string()),
|
||||
count as usize,
|
||||
);
|
||||
}
|
||||
|
||||
return Ok(Stats {
|
||||
rate,
|
||||
country_codes: Some(country_codes),
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(Stats {
|
||||
rate,
|
||||
country_codes: None,
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -91,6 +91,12 @@ pub async fn init_app_state(
|
||||
|
||||
let jwt = JwtHelper::init_from_path(&data_dir).await?;
|
||||
|
||||
// Init geoip if present.
|
||||
let geoip_db_path = data_dir.root().join("GeoLite2-Country.mmdb");
|
||||
if let Err(err) = trailbase_sqlite::load_geoip_db(geoip_db_path.clone()) {
|
||||
debug!("Failed to load maxmind geoip DB '{geoip_db_path:?}': {err}");
|
||||
}
|
||||
|
||||
let app_state = AppState::new(
|
||||
data_dir.clone(),
|
||||
public_dir,
|
||||
|
||||
@@ -12,6 +12,7 @@ argon2 = "0.5.3"
|
||||
base64 = "0.22.1"
|
||||
jsonschema = { version = "0.26.0", default-features = false }
|
||||
lru = { version = "0.12.3", default-features = false }
|
||||
maxminddb = "0.24.0"
|
||||
parking_lot = { version = "0.12.3", default-features = false }
|
||||
rand = "0.8.5"
|
||||
regex = "1.11.0"
|
||||
|
||||
@@ -5,6 +5,7 @@ use sqlite_loadable::{define_scalar_function, define_scalar_void_function};
|
||||
use uuid::*;
|
||||
|
||||
pub mod jsonschema;
|
||||
pub mod maxminddb;
|
||||
pub mod password;
|
||||
|
||||
mod uuid;
|
||||
@@ -114,6 +115,13 @@ pub fn sqlite3_extension_init(db: *mut sqlite3) -> Result<(), sqlite_loadable::E
|
||||
validators::is_json,
|
||||
FunctionFlags::UTF8 | FunctionFlags::DETERMINISTIC | FunctionFlags::INNOCUOUS,
|
||||
)?;
|
||||
define_scalar_function(
|
||||
db,
|
||||
"geoip_country",
|
||||
1,
|
||||
maxminddb::geoip_country,
|
||||
FunctionFlags::UTF8 | FunctionFlags::INNOCUOUS,
|
||||
)?;
|
||||
|
||||
// Lastly init sqlean's "define" for application-defined functions defined in pure SQL.
|
||||
// See: https://github.com/nalgeon/sqlean/blob/main/docs/define.md
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
use maxminddb::{MaxMindDBError, Reader};
|
||||
use parking_lot::Mutex;
|
||||
use sqlite_loadable::prelude::*;
|
||||
use sqlite_loadable::{api, Error as SqliteError};
|
||||
use std::net::IpAddr;
|
||||
use std::path::Path;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
static READER: LazyLock<Mutex<Option<Reader<Vec<u8>>>>> = LazyLock::new(|| Mutex::new(None));
|
||||
|
||||
pub fn load_geoip_db(path: impl AsRef<Path>) -> Result<(), MaxMindDBError> {
|
||||
let reader = Reader::open_readfile(path)?;
|
||||
*READER.lock() = Some(reader);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
pub fn has_geoip_db() -> bool {
|
||||
return READER.lock().is_some();
|
||||
}
|
||||
|
||||
pub(crate) fn geoip_country(
|
||||
context: *mut sqlite3_context,
|
||||
values: &[*mut sqlite3_value],
|
||||
) -> Result<(), SqliteError> {
|
||||
let client_ip_value = values
|
||||
.first()
|
||||
.ok_or_else(|| SqliteError::new_message("Missing argument"))?;
|
||||
if api::value_is_null(client_ip_value) {
|
||||
api::result_null(context);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let text = api::value_text(client_ip_value)?;
|
||||
if text.is_empty() {
|
||||
api::result_null(context);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let client_ip: IpAddr = text.parse().map_err(|err| {
|
||||
SqliteError::new_message(format!("Parsing ip '{client_ip_value:?}' failed: {err}"))
|
||||
})?;
|
||||
|
||||
let cc: Option<String> = READER.lock().as_ref().and_then(|reader| {
|
||||
let country: maxminddb::geoip2::Country = reader.lookup(client_ip).ok()?;
|
||||
return Some(country.country?.iso_code?.to_owned());
|
||||
});
|
||||
|
||||
match cc {
|
||||
Some(cc) => {
|
||||
api::result_text(context, cc)?;
|
||||
}
|
||||
None => {
|
||||
api::result_null(context);
|
||||
}
|
||||
};
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use crate::query_row;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_explicit_jsonschema() {
|
||||
let ip = "89.160.20.112";
|
||||
let conn = crate::connect().await.unwrap();
|
||||
|
||||
let cc: Option<String> = query_row(&conn, &format!("SELECT geoip_country('{ip}')"), ())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.get(0)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(cc, None);
|
||||
|
||||
load_geoip_db("testdata/GeoIP2-Country-Test.mmdb").unwrap();
|
||||
|
||||
let cc: String = query_row(&conn, &format!("SELECT geoip_country('{ip}')"), ())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.get(0)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(cc, "SE");
|
||||
|
||||
let cc: Option<String> = query_row(&conn, &format!("SELECT geoip_country('127.0.0.1')"), ())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.get(0)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(cc, None);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -6,6 +6,14 @@ pub use schema::set_user_schemas;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn load_geoip_db(path: PathBuf) -> Result<(), String> {
|
||||
return trailbase_extension::maxminddb::load_geoip_db(path).map_err(|err| err.to_string());
|
||||
}
|
||||
|
||||
pub fn has_geoip_db() -> bool {
|
||||
return trailbase_extension::maxminddb::has_geoip_db();
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
unsafe extern "C" fn init_extension(
|
||||
db: *mut libsql::ffi::sqlite3,
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
<meta name="description" content="Trailbase Admin Dashboard" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; object-src 'none'; style-src 'self' 'unsafe-inline'; media-src 'none'; frame-src 'self'; img-src 'self' data: http://localhost:4000; font-src 'self' data:; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; manifest-src 'self'; connect-src 'self' http://localhost:4000" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; object-src 'none'; style-src 'self' 'unsafe-inline'; media-src 'none'; frame-src 'self'; img-src 'self' data: http://localhost:4000 https://tile.openstreetmap.org; font-src 'self' data:; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; manifest-src 'self'; connect-src 'self' http://localhost:4000" />
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="/src/assets/favicon.svg" />
|
||||
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
"chart.js": "^4.4.6",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"geojson": "^0.5.0",
|
||||
"i18n-iso-countries": "^7.12.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"long": "^5.2.3",
|
||||
"nanostores": "^0.11.3",
|
||||
"protobufjs": "^7.4.0",
|
||||
@@ -48,6 +51,8 @@
|
||||
"@eslint/js": "^9.13.0",
|
||||
"@iconify-json/tabler": "^1.2.6",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@types/geojson": "^7946.0.14",
|
||||
"@types/leaflet": "^1.9.14",
|
||||
"@types/wicg-file-system-access": "^2023.10.5",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.13.0",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,12 @@
|
||||
import {
|
||||
For,
|
||||
Match,
|
||||
Switch,
|
||||
createEffect,
|
||||
createResource,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
onMount,
|
||||
} from "solid-js";
|
||||
import { useSearchParams } from "@solidjs/router";
|
||||
import {
|
||||
@@ -18,7 +20,11 @@ import type {
|
||||
ScriptableLineSegmentContext,
|
||||
TooltipItem,
|
||||
} from "chart.js/auto";
|
||||
import { TbRefresh } from "solid-icons/tb";
|
||||
import { TbRefresh, TbWorld } from "solid-icons/tb";
|
||||
import { numericToAlpha2 } from "i18n-iso-countries";
|
||||
import type { FeatureCollection, Feature } from "geojson";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import * as L from "leaflet";
|
||||
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
@@ -32,6 +38,8 @@ import { FilterBar } from "@/components/FilterBar";
|
||||
import type { LogJson, ListLogsResponse, Stats } from "@/lib/bindings";
|
||||
import { adminFetch } from "@/lib/fetch";
|
||||
|
||||
import countriesGeoJSON from "@/assets/countries-110m.json";
|
||||
|
||||
const columnHelper = createColumnHelper<LogJson>();
|
||||
|
||||
const columns: ColumnDef<LogJson>[] = [
|
||||
@@ -76,6 +84,10 @@ const columns: ColumnDef<LogJson>[] = [
|
||||
header: "Latency (ms)",
|
||||
},
|
||||
{ accessorKey: "client_ip" },
|
||||
{
|
||||
accessorKey: "client_cc",
|
||||
header: "Country Code",
|
||||
},
|
||||
{ accessorKey: "referer" },
|
||||
{
|
||||
accessorKey: "user_agent",
|
||||
@@ -175,43 +187,61 @@ export function LogsPage() {
|
||||
};
|
||||
};
|
||||
const [logsFetch, { refetch }] = createResource(getLogsProps, getLogs);
|
||||
const [showMap, setShowMap] = createSignal(true);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="m-4 flex items-center gap-2">
|
||||
<h1 class="text-accent-600 m-0">Logs</h1>
|
||||
<div class="m-4 flex justify-between items-center gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="text-accent-600 m-0">Logs</h1>
|
||||
|
||||
<button class="p-1 rounded hover:bg-gray-200" onClick={refetch}>
|
||||
<TbRefresh size={20} />
|
||||
<button class="p-1 rounded hover:bg-gray-200" onClick={refetch}>
|
||||
<TbRefresh size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class={`p-1 rounded hover:bg-gray-200 ${showMap() && "bg-gray-200"}`}
|
||||
onClick={() => setShowMap(!showMap())}
|
||||
>
|
||||
<TbWorld size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="p-4 flex flex-col gap-8">
|
||||
<FilterBar
|
||||
onSubmit={(value: string) => {
|
||||
if (value === filter()) {
|
||||
refetch();
|
||||
} else {
|
||||
setFilter(value);
|
||||
}
|
||||
}}
|
||||
example='e.g. "latency[lt]=2 AND status=200"'
|
||||
/>
|
||||
|
||||
<Switch>
|
||||
<Match when={logsFetch.loading}>
|
||||
<p>Loading...</p>
|
||||
</Match>
|
||||
|
||||
<div class="p-4 flex flex-col gap-4">
|
||||
<Switch fallback={<p>Loading...</p>}>
|
||||
<Match when={logsFetch.error}>Error {`${logsFetch.error}`}</Match>
|
||||
|
||||
<Match when={!logsFetch.error}>
|
||||
{pagination().pageIndex === 0 && (
|
||||
<LogsChart stats={logsFetch()!.stats!} />
|
||||
<Match when={logsFetch.state === "ready"}>
|
||||
{pagination().pageIndex === 0 && logsFetch()!.stats && (
|
||||
<div class="flex w-full h-[300px] gap-4 mb-4">
|
||||
<div class={showMap() ? "w-1/2 grow" : "w-full"}>
|
||||
<LogsChart stats={logsFetch()!.stats!} />
|
||||
</div>
|
||||
|
||||
{showMap() && logsFetch()!.stats?.country_codes && (
|
||||
<div class="w-1/2 max-w-[500px] flex items-center">
|
||||
<WorldMap
|
||||
country_codes={logsFetch()!.stats!.country_codes!}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FilterBar
|
||||
onSubmit={(value: string) => {
|
||||
if (value === filter()) {
|
||||
refetch();
|
||||
} else {
|
||||
setFilter(value);
|
||||
}
|
||||
}}
|
||||
example='e.g. "latency[lt]=2 AND status=200"'
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={() => columns}
|
||||
data={() => logsFetch()?.entries}
|
||||
@@ -236,11 +266,177 @@ function changeDistantPointLineColorToTransparent(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function LogsChart(props: { stats?: Stats }) {
|
||||
const stats = props.stats;
|
||||
if (!stats) {
|
||||
return null;
|
||||
function getColor(d: number) {
|
||||
if (d > 1000) {
|
||||
return "#800026";
|
||||
} else if (d > 500) {
|
||||
return "#BD0026";
|
||||
} else if (d > 200) {
|
||||
return "#E31A1C";
|
||||
} else if (d > 100) {
|
||||
return "#FC4E2A";
|
||||
} else if (d > 50) {
|
||||
return "#FD8D3C";
|
||||
} else if (d > 20) {
|
||||
return "#FEB24C";
|
||||
} else if (d > 10) {
|
||||
return "#FED976";
|
||||
} else if (d > 0) {
|
||||
return "#FFEDA0";
|
||||
} else {
|
||||
return "#FFFFFF";
|
||||
}
|
||||
}
|
||||
|
||||
function mapStyle(
|
||||
codes: { [key in string]?: number },
|
||||
feature: Feature | undefined,
|
||||
) {
|
||||
if (!feature) return {};
|
||||
|
||||
return {
|
||||
fillColor: getColor(
|
||||
codes[numericToAlpha2(feature.id as string) ?? ""] ?? 0,
|
||||
),
|
||||
weight: 2,
|
||||
opacity: 1,
|
||||
color: "white",
|
||||
dashArray: "3",
|
||||
fillOpacity: 0.6,
|
||||
};
|
||||
}
|
||||
|
||||
const Legend = L.Control.extend({
|
||||
options: {
|
||||
position: "bottomright",
|
||||
},
|
||||
onAdd: (_map: L.Map) => {
|
||||
const grades = [1, 20, 50, 100, 200, 500, 1000];
|
||||
return (
|
||||
<div class="flex flex-col bg-white bg-opacity-70 rounded p-1">
|
||||
<For each={grades}>
|
||||
{(grade: number, index: () => number) => {
|
||||
const i = index();
|
||||
return (
|
||||
<div class="flex">
|
||||
<div
|
||||
class="px-2 py-1 mr-1"
|
||||
style={{ background: getColor(grade) }}
|
||||
/>{" "}
|
||||
{grade} {i + 1 < grades.length ? `- ${grades[i + 1]}` : "+"}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function WorldMap(props: { country_codes: { [key in string]?: number } }) {
|
||||
const codes = props.country_codes;
|
||||
|
||||
let ref: HTMLDivElement | undefined;
|
||||
let map: L.Map | undefined;
|
||||
|
||||
const destroy = () => {
|
||||
if (map) {
|
||||
map.off();
|
||||
map.remove();
|
||||
}
|
||||
};
|
||||
|
||||
onCleanup(destroy);
|
||||
onMount(() => {
|
||||
destroy();
|
||||
|
||||
const m = (map = L.map(ref!).setView([30, 0], 1.4));
|
||||
m.attributionControl.setPrefix("");
|
||||
|
||||
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
noWrap: true,
|
||||
maxZoom: 19,
|
||||
attribution:
|
||||
'© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
}).addTo(m);
|
||||
|
||||
// control that shows state info on hover
|
||||
const CustomControl = L.Control.extend({
|
||||
onAdd: (_map: L.Map) => {
|
||||
return (
|
||||
<div class="bg-white bg-opacity-70 p-2 rounded">
|
||||
Hover over a country
|
||||
</div>
|
||||
);
|
||||
},
|
||||
update: function (props?: Props) {
|
||||
const id = props?.id;
|
||||
const requests = codes[numericToAlpha2(id ?? "") ?? ""] ?? 0;
|
||||
const contents = props
|
||||
? `<b>${props.name}</b><br />${requests} req`
|
||||
: "Hover over a country";
|
||||
|
||||
(this as any)._container.innerHTML = contents;
|
||||
},
|
||||
});
|
||||
|
||||
const info = new CustomControl().addTo(m);
|
||||
new Legend().addTo(m);
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const highlightFeature = (e: L.LeafletMouseEvent) => {
|
||||
const layer = e.target;
|
||||
|
||||
layer.setStyle({
|
||||
weight: 2,
|
||||
color: "#666",
|
||||
dashArray: "",
|
||||
fillOpacity: 0.7,
|
||||
});
|
||||
|
||||
layer.bringToFront();
|
||||
|
||||
info.update({
|
||||
id: layer.feature.id,
|
||||
name: layer.feature.properties.name,
|
||||
} as Props);
|
||||
};
|
||||
|
||||
function onEachFeature(_feature: Feature, layer: L.Layer) {
|
||||
layer.on({
|
||||
mouseover: highlightFeature,
|
||||
mouseout: (e: L.LeafletMouseEvent) => {
|
||||
geojson.resetStyle(e.target);
|
||||
info.update();
|
||||
},
|
||||
click: (e: L.LeafletMouseEvent) => m.fitBounds(e.target.getBounds()),
|
||||
});
|
||||
}
|
||||
|
||||
const geojson = L.geoJson(
|
||||
(countriesGeoJSON as FeatureCollection).features,
|
||||
{
|
||||
style: (map) => mapStyle(codes, map),
|
||||
onEachFeature,
|
||||
},
|
||||
).addTo(m);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
class="rounded w-full h-[280px]"
|
||||
style={{ "background-color": "transparent" }}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LogsChart(props: { stats: Stats }) {
|
||||
const stats = props.stats;
|
||||
|
||||
const data = (): ChartData | undefined => {
|
||||
const s = stats;
|
||||
|
||||
Reference in New Issue
Block a user