Merge branch 'leaflet'

This commit is contained in:
Sebastian Jeltsch
2024-11-05 19:36:24 +01:00
15 changed files with 13667 additions and 43 deletions
Generated
+22
View File
@@ -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",
+51
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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, };
+72 -11
View File
@@ -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)]
+6
View File
@@ -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,
+1
View File
@@ -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"
+8
View File
@@ -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
+100
View File
@@ -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

+8
View File
@@ -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
View File
@@ -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" />
+5
View File
@@ -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
+225 -29
View File
@@ -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:
'&copy; <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;