mirror of
https://github.com/4cuck/nitter-status.git
synced 2026-10-26 00:52:25 +00:00
Gather .health instance stats
Reworks instance checks to use the same timestamp and be transactional. Also prevents long DB lock times during list updates.
This commit is contained in:
1 parent
07ae70124e
commit
98ac43723e
22 files changed
+598
-87
No files matched your search
@@ -52,3 +52,5 @@ CLEANUP_INTERVAL_S=86400
|
|||||||
ERROR_RETENTION_PER_HOST=100
|
ERROR_RETENTION_PER_HOST=100
|
||||||
# session database URI
|
# session database URI
|
||||||
SESSION_DB_URI="sqlite:./sessions.db?mode=rwc"
|
SESSION_DB_URI="sqlite:./sessions.db?mode=rwc"
|
||||||
|
# interval in seconds for gathering .health endpoint data
|
||||||
|
STATS_INTERVAL_S=900
|
||||||
@@ -4,3 +4,4 @@
|
|||||||
/.env
|
/.env
|
||||||
/sqlite.db-journal
|
/sqlite.db-journal
|
||||||
/out.txt
|
/out.txt
|
||||||
|
/sessions.db
|
||||||
Generated
+2
-1
@@ -2244,7 +2244,7 @@ checksum = "e4a24736216ec316047a1fc4252e27dabb04218aa4a3f37c6e7ddbf1f9782b54"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nitter-status"
|
name = "nitter-status"
|
||||||
version = "0.4.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -3229,6 +3229,7 @@ dependencies = [
|
|||||||
"sea-orm",
|
"sea-orm",
|
||||||
"sea-query",
|
"sea-query",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "nitter-status"
|
name = "nitter-status"
|
||||||
version = "0.4.0"
|
version = "0.5.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
license = "AGPL-3.0-only"
|
license = "AGPL-3.0-only"
|
||||||
@@ -15,7 +15,7 @@ chrono = "0.4.22"
|
|||||||
serde = "1.0.144"
|
serde = "1.0.144"
|
||||||
sea-orm-migration = "0.12.0"
|
sea-orm-migration = "0.12.0"
|
||||||
sea-orm = "0.12.0"
|
sea-orm = "0.12.0"
|
||||||
sea-query = "0.30" # keep in step sea-orm
|
sea-query = "0.30" # keep in step with sea-orm
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = "0.3"
|
tracing-subscriber = "0.3"
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ pub struct HealthyAmount {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl HealthyAmount {
|
impl HealthyAmount {
|
||||||
|
/// Fetch health check graph data for all or selected hosts in the selected time range.
|
||||||
pub async fn fetch(db: &DatabaseConnection, from: DateTimeUtc, to: DateTimeUtc, hosts: Option<&[i32]>) -> Result<Vec<HealthyAmount>,DbErr> {
|
pub async fn fetch(db: &DatabaseConnection, from: DateTimeUtc, to: DateTimeUtc, hosts: Option<&[i32]>) -> Result<Vec<HealthyAmount>,DbErr> {
|
||||||
let builder = db.get_database_backend();
|
let builder = db.get_database_backend();
|
||||||
let mut stmt: sea_query::SelectStatement = Query::select();
|
let mut stmt: sea_query::SelectStatement = Query::select();
|
||||||
@@ -90,6 +91,6 @@ mod test {
|
|||||||
.checked_sub_signed(chrono::Duration::days(14))
|
.checked_sub_signed(chrono::Duration::days(14))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let hosts = vec![1,2,3];
|
let hosts = vec![1,2,3];
|
||||||
dbg!(HealthyAmount::fetch(&db, time_3h, time_now,Some(&hosts)).await.unwrap());
|
HealthyAmount::fetch(&db, time_3h, time_now,Some(&hosts)).await.unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.3
|
||||||
|
|
||||||
|
use sea_orm::{entity::prelude::*, FromQueryResult};
|
||||||
|
use sea_query::{Query, SimpleExpr, Order, Alias};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize)]
|
||||||
|
#[sea_orm(table_name = "instance_stats")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub time: i64,
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub host: i32,
|
||||||
|
pub limited_accs: i32,
|
||||||
|
pub total_accs: i32,
|
||||||
|
pub total_requests: i64,
|
||||||
|
pub req_photo_rail: i32,
|
||||||
|
pub req_user_screen_name: i32,
|
||||||
|
pub req_search: i32,
|
||||||
|
pub req_list_tweets: i32,
|
||||||
|
pub req_user_media: i32,
|
||||||
|
pub req_tweet_detail: i32,
|
||||||
|
pub req_list: i32,
|
||||||
|
pub req_user_tweets: i32,
|
||||||
|
pub req_user_tweets_and_replies: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::host::Entity",
|
||||||
|
from = "Column::Host",
|
||||||
|
to = "super::host::Column::Id",
|
||||||
|
on_update = "Cascade",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
Host,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::host::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Host.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
|
||||||
|
#[derive(Debug, FromQueryResult, Serialize)]
|
||||||
|
pub struct StatsAmount {
|
||||||
|
pub time: i64,
|
||||||
|
pub limited_accs_max: i32,
|
||||||
|
pub limited_accs_avg: i32,
|
||||||
|
pub total_accs_max: i32,
|
||||||
|
pub total_accs_avg: i32,
|
||||||
|
pub total_requests_max: i64,
|
||||||
|
pub total_requests_avg: i64,
|
||||||
|
pub req_photo_rail_max: i32,
|
||||||
|
pub req_photo_rail_avg: i32,
|
||||||
|
pub req_user_screen_name_max: i32,
|
||||||
|
pub req_user_screen_name_avg: i32,
|
||||||
|
pub req_search_max: i32,
|
||||||
|
pub req_search_avg: i32,
|
||||||
|
pub req_list_tweets_max: i32,
|
||||||
|
pub req_list_tweets_avg: i32,
|
||||||
|
pub req_user_media_max: i32,
|
||||||
|
pub req_user_media_avg: i32,
|
||||||
|
pub req_tweet_detail_max: i32,
|
||||||
|
pub req_tweet_detail_avg: i32,
|
||||||
|
pub req_list_max: i32,
|
||||||
|
pub req_list_avg: i32,
|
||||||
|
pub req_user_tweets_max: i32,
|
||||||
|
pub req_user_tweets_avg: i32,
|
||||||
|
pub req_user_tweets_and_replies_max: i32,
|
||||||
|
pub req_user_tweets_and_replies_avg: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StatsAmount {
|
||||||
|
/// Fetch health check graph data for all or selected hosts in the selected time range.
|
||||||
|
pub async fn fetch(db: &DatabaseConnection, from: DateTimeUtc, to: DateTimeUtc, hosts: Option<&[i32]>) -> Result<Vec<StatsAmount>,DbErr> {
|
||||||
|
let builder = db.get_database_backend();
|
||||||
|
let columns = ["limited_accs","total_accs","total_requests","req_photo_rail","req_user_screen_name","req_search","req_list_tweets","req_user_media","req_tweet_detail","req_list","req_user_tweets","req_user_tweets_and_replies"];
|
||||||
|
let mut stmt: sea_query::SelectStatement = Query::select();
|
||||||
|
let col_stmt = stmt.column(self::Column::Time);
|
||||||
|
for col in columns {
|
||||||
|
col_stmt.expr_as(SimpleExpr::Custom(format!("MAX({col})")), Alias::new(format!("{col}_max")))
|
||||||
|
.expr_as(SimpleExpr::Custom(format!("CAST(ifnull(AVG({col}),0) as int)")), Alias::new(format!("{col}_avg")));
|
||||||
|
}
|
||||||
|
col_stmt.group_by_col(self::Column::Time)
|
||||||
|
.from(self::Entity)
|
||||||
|
.and_where(self::Column::Time.between(from.timestamp(), to.timestamp()));
|
||||||
|
if let Some(hosts) = hosts {
|
||||||
|
stmt.and_where(self::Column::Host.is_in(hosts.iter().map(|v| *v)));
|
||||||
|
}
|
||||||
|
stmt.group_by_col(self::Column::Time)
|
||||||
|
.order_by(self::Column::Time, Order::Asc);
|
||||||
|
StatsAmount::find_by_statement(builder.build(&stmt)).all(db).await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ pub mod prelude;
|
|||||||
pub mod check_errors;
|
pub mod check_errors;
|
||||||
pub mod health_check;
|
pub mod health_check;
|
||||||
pub mod host;
|
pub mod host;
|
||||||
|
pub mod instance_stats;
|
||||||
|
|
||||||
// has to be re-added on entity regeneration
|
// has to be re-added on entity regeneration
|
||||||
pub mod state;
|
pub mod state;
|
||||||
@@ -3,3 +3,4 @@
|
|||||||
pub use super::check_errors::Entity as CheckErrors;
|
pub use super::check_errors::Entity as CheckErrors;
|
||||||
pub use super::health_check::Entity as HealthCheck;
|
pub use super::health_check::Entity as HealthCheck;
|
||||||
pub use super::host::Entity as Host;
|
pub use super::host::Entity as Host;
|
||||||
|
pub use super::instance_stats::Entity as InstanceStats;
|
||||||
@@ -7,6 +7,8 @@ pub struct Config {
|
|||||||
pub list_fetch_interval: Duration,
|
pub list_fetch_interval: Duration,
|
||||||
/// time until next instance ping check
|
/// time until next instance ping check
|
||||||
pub instance_check_interval: Duration,
|
pub instance_check_interval: Duration,
|
||||||
|
/// time until next instance statistics check
|
||||||
|
pub instance_stats_interval: Duration,
|
||||||
/// instances list URL
|
/// instances list URL
|
||||||
pub instance_list_url: String,
|
pub instance_list_url: String,
|
||||||
/// profile path for health check
|
/// profile path for health check
|
||||||
@@ -48,8 +50,9 @@ pub struct Config {
|
|||||||
impl Config {
|
impl Config {
|
||||||
pub fn test_defaults() -> ScannerConfig {
|
pub fn test_defaults() -> ScannerConfig {
|
||||||
Arc::new(Config {
|
Arc::new(Config {
|
||||||
list_fetch_interval: Duration::from_secs(60 * 5),
|
instance_stats_interval: Duration::from_secs(15 * 60),
|
||||||
instance_check_interval: Duration::from_secs(60 * 5),
|
list_fetch_interval: Duration::from_secs(15 * 60),
|
||||||
|
instance_check_interval: Duration::from_secs(15 * 60),
|
||||||
instance_list_url: String::from("https://github.com/zedeus/nitter/wiki/Instances"),
|
instance_list_url: String::from("https://github.com/zedeus/nitter/wiki/Instances"),
|
||||||
profile_path: String::from("/jack"),
|
profile_path: String::from("/jack"),
|
||||||
rss_path: String::from("/jack/rss"),
|
rss_path: String::from("/jack/rss"),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ mod m20230803_154714_version_url;
|
|||||||
mod m20230829_201916_country;
|
mod m20230829_201916_country;
|
||||||
mod m20230914_231514_connectivity;
|
mod m20230914_231514_connectivity;
|
||||||
mod m20231011_231223_errors;
|
mod m20231011_231223_errors;
|
||||||
|
mod m20231112_142206_stats;
|
||||||
|
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20230829_201916_country::Migration),
|
Box::new(m20230829_201916_country::Migration),
|
||||||
Box::new(m20230914_231514_connectivity::Migration),
|
Box::new(m20230914_231514_connectivity::Migration),
|
||||||
Box::new(m20231011_231223_errors::Migration),
|
Box::new(m20231011_231223_errors::Migration),
|
||||||
|
Box::new(m20231112_142206_stats::Migration),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
let cmd = r#"CREATE TABLE "instance_stats" (
|
||||||
|
"time" integer NOT NULL,
|
||||||
|
"host" integer NOT NULL,
|
||||||
|
"limited_accs" integer NOT NULL,
|
||||||
|
"total_accs" integer NOT NULL,
|
||||||
|
"total_requests" integer NOT NULL,
|
||||||
|
"req_photo_rail" integer NOT NULL,
|
||||||
|
"req_user_screen_name" integer NOT NULL,
|
||||||
|
"req_search" integer NOT NULL,
|
||||||
|
"req_list_tweets" integer NOT NULL,
|
||||||
|
"req_user_media" integer NOT NULL,
|
||||||
|
"req_tweet_detail" integer NOT NULL,
|
||||||
|
"req_list" integer NOT NULL,
|
||||||
|
"req_user_tweets" integer NOT NULL,
|
||||||
|
"req_user_tweets_and_replies" integer NOT NULL,
|
||||||
|
CONSTRAINT "pk_instance_stats" PRIMARY KEY ("host", "time"),
|
||||||
|
FOREIGN KEY ("host") REFERENCES "host" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
) WITHOUT ROWID, STRICT;"#;
|
||||||
|
let db = manager.get_connection();
|
||||||
|
db.execute_unprepared("BEGIN EXCLUSIVE").await?;
|
||||||
|
tracing::info!("adding instance_stats table..");
|
||||||
|
db.execute_unprepared(cmd).await?;
|
||||||
|
db.execute_unprepared("COMMIT TRANSACTION").await?;
|
||||||
|
db.execute_unprepared("VACUUM").await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
panic!("Can't migrate down");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ regex = { workspace = true }
|
|||||||
git2 = "0.17.2"
|
git2 = "0.17.2"
|
||||||
# testing
|
# testing
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
# health parsing
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tracing-test = { workspace = true }
|
tracing-test = { workspace = true }
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ impl Scanner {
|
|||||||
.map_or(false, |check| !check.healthy);
|
.map_or(false, |check| !check.healthy);
|
||||||
join_set.spawn(async move {
|
join_set.spawn(async move {
|
||||||
scanner.health_check_host(model, muted_host).await;
|
scanner.health_check_host(model, muted_host).await;
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// wait till all of them are finished, preventing DoS
|
// wait till all of them are finished, preventing DoS
|
||||||
|
|||||||
+47
-14
@@ -35,6 +35,7 @@ mod instance_parser;
|
|||||||
mod list_update;
|
mod list_update;
|
||||||
mod profile_parser;
|
mod profile_parser;
|
||||||
mod version_check;
|
mod version_check;
|
||||||
|
mod update_stats;
|
||||||
|
|
||||||
const CAPTCHA_TEXT: &'static str = "Enable JavaScript and cookies to continue";
|
const CAPTCHA_TEXT: &'static str = "Enable JavaScript and cookies to continue";
|
||||||
const CAPTCHA_CODE: u16 = 403;
|
const CAPTCHA_CODE: u16 = 403;
|
||||||
@@ -62,8 +63,18 @@ pub enum ScannerError {
|
|||||||
FetchError(#[from] FetchError),
|
FetchError(#[from] FetchError),
|
||||||
#[error("Failed fetching git: {0}")]
|
#[error("Failed fetching git: {0}")]
|
||||||
GitFetch(#[from] git2::Error),
|
GitFetch(#[from] git2::Error),
|
||||||
#[error("Couldn't find git branch")]
|
/// Configured git branch can't be found
|
||||||
GitBranch,
|
#[error("Couldn't find configured git branch")]
|
||||||
|
GitBranchNotFound,
|
||||||
|
/// Failed to parse .health endpoint
|
||||||
|
#[error("Failed to parse .health endpoint. {0} Body: {1}")]
|
||||||
|
StatsParsing(serde_json::Error, String),
|
||||||
|
/// Generic error for parsing an instance URL to [reqwest::Url]
|
||||||
|
#[error("Can't parse instance URL")]
|
||||||
|
InstanceUrlParse,
|
||||||
|
/// Failed to join a tokio thread in a JoinSet
|
||||||
|
#[error("Failed to join worker task {0}")]
|
||||||
|
JoinError(#[from] tokio::task::JoinError)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
@@ -154,6 +165,7 @@ struct InnerScanner {
|
|||||||
about_parser: AboutParser,
|
about_parser: AboutParser,
|
||||||
profile_parser: ProfileParser,
|
profile_parser: ProfileParser,
|
||||||
last_list_fetch: Mutex<DateTime<Utc>>,
|
last_list_fetch: Mutex<DateTime<Utc>>,
|
||||||
|
last_stats_fetch: Mutex<DateTime<Utc>>,
|
||||||
last_uptime_check: Mutex<DateTime<Utc>>,
|
last_uptime_check: Mutex<DateTime<Utc>>,
|
||||||
rss_check_regex: Regex,
|
rss_check_regex: Regex,
|
||||||
client_ipv4: Client,
|
client_ipv4: Client,
|
||||||
@@ -197,11 +209,16 @@ impl Scanner {
|
|||||||
.build()
|
.build()
|
||||||
.into_diagnostic()?;
|
.into_diagnostic()?;
|
||||||
|
|
||||||
let last_uptime_check = Self::query_last_fetch(&db)
|
let last_uptime_check = Self::query_last_entry_for_table(&db,"health_check")
|
||||||
.await
|
.await
|
||||||
.into_diagnostic()
|
.into_diagnostic()
|
||||||
.wrap_err("Fetching last uptime check failed!")?;
|
.wrap_err("Fetching last uptime check failed!")?;
|
||||||
tracing::info!(?last_uptime_check);
|
|
||||||
|
let last_stats_check = Self::query_last_entry_for_table(&db,"instance_stats")
|
||||||
|
.await
|
||||||
|
.into_diagnostic()
|
||||||
|
.wrap_err("Fetching last stats check failed!")?;
|
||||||
|
tracing::info!(?last_uptime_check, ?last_stats_check);// TODO: wrong timestamp
|
||||||
let scanner = Self {
|
let scanner = Self {
|
||||||
inner: Arc::new(InnerScanner {
|
inner: Arc::new(InnerScanner {
|
||||||
db,
|
db,
|
||||||
@@ -215,6 +232,7 @@ impl Scanner {
|
|||||||
profile_parser: ProfileParser::new(),
|
profile_parser: ProfileParser::new(),
|
||||||
last_list_fetch: Mutex::new(last_uptime_check),
|
last_list_fetch: Mutex::new(last_uptime_check),
|
||||||
last_uptime_check: Mutex::new(last_uptime_check),
|
last_uptime_check: Mutex::new(last_uptime_check),
|
||||||
|
last_stats_fetch: Mutex::new(last_stats_check),
|
||||||
rss_check_regex: builder_regex_rss
|
rss_check_regex: builder_regex_rss
|
||||||
.build()
|
.build()
|
||||||
.into_diagnostic()
|
.into_diagnostic()
|
||||||
@@ -229,8 +247,8 @@ impl Scanner {
|
|||||||
Ok(scanner)
|
Ok(scanner)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieves the last uptime fetch that happened
|
/// Retrieves the last stats fetching that happened
|
||||||
pub async fn query_last_fetch(db: &DatabaseConnection) -> Result<DateTime<Utc>> {
|
pub async fn query_last_entry_for_table(db: &DatabaseConnection, table: &str) -> Result<DateTime<Utc>> {
|
||||||
#[derive(Debug, FromQueryResult, Default)]
|
#[derive(Debug, FromQueryResult, Default)]
|
||||||
pub(crate) struct TimeMax {
|
pub(crate) struct TimeMax {
|
||||||
pub max: Option<i64>,
|
pub max: Option<i64>,
|
||||||
@@ -239,7 +257,7 @@ impl Scanner {
|
|||||||
// will crash on an empty DB
|
// will crash on an empty DB
|
||||||
let time_max = TimeMax::find_by_statement(Statement::from_sql_and_values(
|
let time_max = TimeMax::find_by_statement(Statement::from_sql_and_values(
|
||||||
DbBackend::Sqlite,
|
DbBackend::Sqlite,
|
||||||
r#"SELECT MAX(time) FROM health_check"#,
|
format!("SELECT MAX(time) as max FROM {table}"),
|
||||||
[],
|
[],
|
||||||
))
|
))
|
||||||
.one(db)
|
.one(db)
|
||||||
@@ -282,23 +300,28 @@ impl Scanner {
|
|||||||
}
|
}
|
||||||
if self.is_instance_check_outdated() {
|
if self.is_instance_check_outdated() {
|
||||||
if let Err(e) = self.check_uptime().await {
|
if let Err(e) = self.check_uptime().await {
|
||||||
tracing::error!(error=?e,"Failed checking instance");
|
tracing::error!(error=?e,"Failed checking instances for health");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.is_instance_stats_outdated() {
|
||||||
|
if let Err(e) = self.check_health().await {
|
||||||
|
tracing::error!(error=?e,"Failed checking instances for stats");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Err(e) = self.update_cache().await {
|
if let Err(e) = self.update_cache().await {
|
||||||
tracing::error!(error=?e,"Failed updating cache!");
|
tracing::error!(error=?e,"Failed to update cache!");
|
||||||
}
|
}
|
||||||
self.sleep_till_deadline().await;
|
self.sleep_till_deadline().await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn sleep_till_deadline(&self) {
|
async fn sleep_till_deadline(&self) {
|
||||||
let delay_instance_check =
|
let delay_instance_check = self.last_uptime_check() + self.inner.config.instance_check_interval;
|
||||||
self.last_uptime_check() + self.inner.config.instance_check_interval;
|
|
||||||
|
|
||||||
let delay_list_update = self.last_list_fetch() + self.inner.config.list_fetch_interval;
|
let delay_list_update = self.last_list_fetch() + self.inner.config.list_fetch_interval;
|
||||||
tracing::debug!(?delay_list_update, ?delay_instance_check);
|
let delay_stats_update = self.last_stats_fetch() + self.inner.config.instance_stats_interval;
|
||||||
let next_deadline = delay_instance_check.min(delay_list_update);
|
tracing::debug!(?delay_list_update, ?delay_instance_check, ?delay_stats_update);
|
||||||
|
|
||||||
|
let next_deadline = delay_instance_check.min(delay_list_update).min(delay_stats_update);
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let sleep_time = next_deadline.signed_duration_since(now);
|
let sleep_time = next_deadline.signed_duration_since(now);
|
||||||
if sleep_time <= Duration::zero() {
|
if sleep_time <= Duration::zero() {
|
||||||
@@ -318,6 +341,10 @@ impl Scanner {
|
|||||||
*self.inner.last_list_fetch.lock().unwrap()
|
*self.inner.last_list_fetch.lock().unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn last_stats_fetch(&self) -> DateTime<Utc> {
|
||||||
|
*self.inner.last_stats_fetch.lock().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
fn is_instance_check_outdated(&self) -> bool {
|
fn is_instance_check_outdated(&self) -> bool {
|
||||||
let val = self.last_uptime_check();
|
let val = self.last_uptime_check();
|
||||||
Utc::now().signed_duration_since(val).to_std().unwrap()
|
Utc::now().signed_duration_since(val).to_std().unwrap()
|
||||||
@@ -330,6 +357,12 @@ impl Scanner {
|
|||||||
>= self.inner.config.list_fetch_interval
|
>= self.inner.config.list_fetch_interval
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_instance_stats_outdated(&self) -> bool {
|
||||||
|
let val = self.last_stats_fetch();
|
||||||
|
Utc::now().signed_duration_since(val).to_std().unwrap()
|
||||||
|
>= self.inner.config.instance_stats_interval
|
||||||
|
}
|
||||||
|
|
||||||
async fn fetch_instance_list(&self) -> Result<String> {
|
async fn fetch_instance_list(&self) -> Result<String> {
|
||||||
let (_, body) = self.fetch_url(&self.inner.config.instance_list_url).await?;
|
let (_, body) = self.fetch_url(&self.inner.config.instance_list_url).await?;
|
||||||
Ok(body)
|
Ok(body)
|
||||||
|
|||||||
+26
-17
@@ -59,6 +59,10 @@ impl Scanner {
|
|||||||
let found_instances: usize = parsed_instances.len();
|
let found_instances: usize = parsed_instances.len();
|
||||||
// find last update checks to detect spam
|
// find last update checks to detect spam
|
||||||
let last_status = self.query_latest_check(&transaction).await?;
|
let last_status = self.query_latest_check(&transaction).await?;
|
||||||
|
// prevent blocking the DB forever during host updates
|
||||||
|
// worst case we disable hosts that should be disabled and add no new ones
|
||||||
|
transaction.commit().await?;
|
||||||
|
|
||||||
let mut join_set = JoinSet::new();
|
let mut join_set = JoinSet::new();
|
||||||
for (_, instance) in parsed_instances {
|
for (_, instance) in parsed_instances {
|
||||||
// TODO: parallelize this!
|
// TODO: parallelize this!
|
||||||
@@ -112,25 +116,30 @@ impl Scanner {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut update_models = Vec::with_capacity(join_set.len());
|
||||||
while let Some(update_model) = join_set.join_next().await.map(|v| v.unwrap()) {
|
while let Some(update_model) = join_set.join_next().await.map(|v| v.unwrap()) {
|
||||||
Host::insert(update_model)
|
update_models.push(update_model);
|
||||||
.on_conflict(
|
|
||||||
OnConflict::column(host::Column::Domain)
|
|
||||||
.update_columns([
|
|
||||||
host::Column::Enabled,
|
|
||||||
host::Column::Updated,
|
|
||||||
host::Column::Url,
|
|
||||||
host::Column::Rss,
|
|
||||||
host::Column::Version,
|
|
||||||
host::Column::VersionUrl,
|
|
||||||
host::Column::Country,
|
|
||||||
host::Column::Connectivity,
|
|
||||||
])
|
|
||||||
.to_owned(),
|
|
||||||
)
|
|
||||||
.exec(&transaction)
|
|
||||||
.await?;
|
|
||||||
}
|
}
|
||||||
|
// insert all at once
|
||||||
|
let transaction = self.inner.db.begin().await?;
|
||||||
|
Host::insert_many(update_models)
|
||||||
|
.on_conflict(
|
||||||
|
OnConflict::column(host::Column::Domain)
|
||||||
|
.update_columns([
|
||||||
|
host::Column::Enabled,
|
||||||
|
host::Column::Updated,
|
||||||
|
host::Column::Url,
|
||||||
|
host::Column::Rss,
|
||||||
|
host::Column::Version,
|
||||||
|
host::Column::VersionUrl,
|
||||||
|
host::Column::Country,
|
||||||
|
host::Column::Connectivity,
|
||||||
|
])
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.exec(&transaction)
|
||||||
|
.await?;
|
||||||
|
|
||||||
transaction.commit().await?;
|
transaction.commit().await?;
|
||||||
let end = Instant::now();
|
let end = Instant::now();
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
//! for .health endpoint statistics gathering
|
||||||
|
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use entities::{host, instance_stats};
|
||||||
|
use entities::prelude::Host;
|
||||||
|
use reqwest::Url;
|
||||||
|
use sea_orm::prelude::DateTimeUtc;
|
||||||
|
use sea_orm::{QueryFilter, ColumnTrait, EntityTrait, ActiveValue};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tokio::task::JoinSet;
|
||||||
|
|
||||||
|
use crate::{Result, Scanner, ScannerError};
|
||||||
|
|
||||||
|
/// Instance stats reported by .health
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct InstanceStats {
|
||||||
|
accounts: InstanceStatsAccs,
|
||||||
|
requests: RequestStats,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct InstanceStatsAccs {
|
||||||
|
total: i32,
|
||||||
|
limited: i32,
|
||||||
|
oldest: DateTimeUtc,
|
||||||
|
newest: DateTimeUtc,
|
||||||
|
average: DateTimeUtc,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct RequestStats {
|
||||||
|
total: i64,
|
||||||
|
apis: APIStats,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Instance api stats reported by .health
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
struct APIStats {
|
||||||
|
pub photoRail: i32,
|
||||||
|
pub userScreenName: i32,
|
||||||
|
pub search: i32,
|
||||||
|
pub listTweets: i32,
|
||||||
|
pub userMedia: i32,
|
||||||
|
pub tweetDetail: i32,
|
||||||
|
pub list: i32,
|
||||||
|
pub userTweets: i32,
|
||||||
|
pub userTweetsAndReplies: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Scanner {
|
||||||
|
pub(crate) async fn check_health(&self) -> Result<()> {
|
||||||
|
let hosts = Host::find()
|
||||||
|
.filter(host::Column::Enabled.eq(true))
|
||||||
|
.all(&self.inner.db)
|
||||||
|
.await?;
|
||||||
|
let start = Instant::now();
|
||||||
|
|
||||||
|
let mut join_set = JoinSet::new();
|
||||||
|
let time = Utc::now();
|
||||||
|
for model in hosts.into_iter() {
|
||||||
|
let scanner = self.clone();
|
||||||
|
join_set.spawn(async move {
|
||||||
|
let res = scanner.fetch_instance_stats(time, &model).await;
|
||||||
|
if let Err(e) = &res {
|
||||||
|
tracing::debug!(host=model.id, error=?e,"Failed to fetch instance stats");
|
||||||
|
}
|
||||||
|
res.ok()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stat_data = Vec::with_capacity(join_set.len());
|
||||||
|
while let Some(join_res) = join_set.join_next().await {
|
||||||
|
if let Some(data) = join_res? {
|
||||||
|
stat_data.push(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::debug!(db_stats_entries=stat_data.len());
|
||||||
|
if !stat_data.is_empty() {
|
||||||
|
instance_stats::Entity::insert_many(stat_data)
|
||||||
|
.exec(&self.inner.db).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let end = Instant::now();
|
||||||
|
let duration = end - start;
|
||||||
|
{
|
||||||
|
*self.inner.last_stats_fetch.lock().unwrap() = Utc::now();
|
||||||
|
}
|
||||||
|
tracing::debug!(duration=?duration,"stats check finished");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_instance_stats(&self, time: DateTimeUtc, host: &host::Model) -> Result<instance_stats::ActiveModel> {
|
||||||
|
let mut url = Url::parse(&host.url)
|
||||||
|
.map_err(|e|ScannerError::InstanceUrlParse)?;
|
||||||
|
url.set_path(".health");
|
||||||
|
let (_code, body) = self.fetch_url(url.as_str()).await?;
|
||||||
|
|
||||||
|
let stats_data: InstanceStats =
|
||||||
|
serde_json::from_str(&body)
|
||||||
|
.map_err(|e|ScannerError::StatsParsing(e,body))?;
|
||||||
|
|
||||||
|
let stats_model = instance_stats::ActiveModel {
|
||||||
|
time: ActiveValue::Set(time.timestamp()),
|
||||||
|
host: ActiveValue::Set(host.id),
|
||||||
|
limited_accs: ActiveValue::Set(stats_data.accounts.limited),
|
||||||
|
total_accs: ActiveValue::Set(stats_data.accounts.total),
|
||||||
|
total_requests: ActiveValue::Set(stats_data.requests.total),
|
||||||
|
req_photo_rail: ActiveValue::Set(stats_data.requests.apis.photoRail),
|
||||||
|
req_user_screen_name: ActiveValue::Set(stats_data.requests.apis.userScreenName),
|
||||||
|
req_search: ActiveValue::Set(stats_data.requests.apis.search),
|
||||||
|
req_list_tweets: ActiveValue::Set(stats_data.requests.apis.listTweets),
|
||||||
|
req_user_media: ActiveValue::Set(stats_data.requests.apis.userMedia),
|
||||||
|
req_tweet_detail: ActiveValue::Set(stats_data.requests.apis.tweetDetail),
|
||||||
|
req_list: ActiveValue::Set(stats_data.requests.apis.list),
|
||||||
|
req_user_tweets: ActiveValue::Set(stats_data.requests.apis.userTweets),
|
||||||
|
req_user_tweets_and_replies: ActiveValue::Set(stats_data.requests.apis.userTweetsAndReplies),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(stats_model)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,7 +56,7 @@ pub(crate) fn fetch_git_state(config: ScannerConfig) -> Result<CurrentVersion> {
|
|||||||
version: commit,
|
version: commit,
|
||||||
config,
|
config,
|
||||||
})
|
})
|
||||||
.ok_or(ScannerError::GitBranch)?)
|
.ok_or(ScannerError::GitBranchNotFound)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
+20
-2
@@ -14,6 +14,7 @@ use constant_time_eq::constant_time_eq;
|
|||||||
use entities::check_errors;
|
use entities::check_errors;
|
||||||
use entities::health_check;
|
use entities::health_check;
|
||||||
use entities::host;
|
use entities::host;
|
||||||
|
use entities::instance_stats;
|
||||||
use entities::state::AppState;
|
use entities::state::AppState;
|
||||||
use hyper::header::REFERER;
|
use hyper::header::REFERER;
|
||||||
use hyper::HeaderMap;
|
use hyper::HeaderMap;
|
||||||
@@ -319,15 +320,18 @@ pub async fn history_json(
|
|||||||
struct ReturnData {
|
struct ReturnData {
|
||||||
pub global: Vec<health_check::HealthyAmount>,
|
pub global: Vec<health_check::HealthyAmount>,
|
||||||
pub user: Vec<health_check::HealthyAmount>,
|
pub user: Vec<health_check::HealthyAmount>,
|
||||||
|
pub stats: Vec<instance_stats::StatsAmount>,
|
||||||
}
|
}
|
||||||
let (_login, hosts) = get_all_login_hosts(&session, db, false).await?;
|
let (_login, hosts) = get_all_login_hosts(&session, db, false).await?;
|
||||||
let host_ids: Vec<_> = hosts.iter().map(|host|host.id).collect();
|
let host_ids: Vec<_> = hosts.iter().map(|host|host.id).collect();
|
||||||
let data_owned = health_check::HealthyAmount::fetch(db, input.start, input.end,Some(&host_ids)).await?;
|
let data_owned = health_check::HealthyAmount::fetch(db, input.start, input.end,Some(&host_ids)).await?;
|
||||||
let data_global = health_check::HealthyAmount::fetch(db, input.start, input.end,None).await?;
|
let data_global = health_check::HealthyAmount::fetch(db, input.start, input.end,None).await?;
|
||||||
|
let data_stats = instance_stats::StatsAmount::fetch(db, input.start, input.end,None).await?;
|
||||||
|
|
||||||
Ok(Json(ReturnData {
|
Ok(Json(ReturnData {
|
||||||
global: data_global,
|
global: data_global,
|
||||||
user: data_owned,
|
user: data_owned,
|
||||||
|
stats: data_stats,
|
||||||
}).into_response())
|
}).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,15 +342,29 @@ pub async fn history_json_specific(
|
|||||||
Json(input): Json<DateRangeInput>,
|
Json(input): Json<DateRangeInput>,
|
||||||
) -> Result<axum::response::Response> {
|
) -> Result<axum::response::Response> {
|
||||||
let host = get_specific_login_host(host, &session, db).await?;
|
let host = get_specific_login_host(host, &session, db).await?;
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ReturnData {
|
||||||
|
pub stats: Vec<instance_stats::Model>,
|
||||||
|
pub health: Vec<health_check::Model>,
|
||||||
|
}
|
||||||
|
|
||||||
let history: Vec<health_check::Model> = health_check::Entity::find()
|
let history_health: Vec<health_check::Model> = health_check::Entity::find()
|
||||||
.filter(health_check::Column::Host.eq(host.id))
|
.filter(health_check::Column::Host.eq(host.id))
|
||||||
.order_by_asc(health_check::Column::Time)
|
.order_by_asc(health_check::Column::Time)
|
||||||
.filter(health_check::Column::Time.between(input.start.timestamp(), input.end.timestamp()))
|
.filter(health_check::Column::Time.between(input.start.timestamp(), input.end.timestamp()))
|
||||||
.all(db)
|
.all(db)
|
||||||
.await?;
|
.await?;
|
||||||
|
let history_stats: Vec<instance_stats::Model> = instance_stats::Entity::find()
|
||||||
|
.filter(instance_stats::Column::Host.eq(host.id))
|
||||||
|
.order_by_asc(instance_stats::Column::Time)
|
||||||
|
.filter(instance_stats::Column::Time.between(input.start.timestamp(), input.end.timestamp()))
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(Json(history).into_response())
|
Ok(Json(ReturnData{
|
||||||
|
health: history_health,
|
||||||
|
stats: history_stats
|
||||||
|
}).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn history_view(
|
pub async fn history_view(
|
||||||
|
|||||||
+173
-37
@@ -39,73 +39,209 @@ async function fetchDataAndCreateChart(startDate,endDate) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch data:', error);
|
console.error('Failed to fetch data:', error);
|
||||||
}
|
}
|
||||||
|
// Create arrays for y-values for both datasets
|
||||||
|
const healthyData = data.global.map(entry => {
|
||||||
|
return {x: moment.unix(entry.time), y: entry.alive};
|
||||||
|
});
|
||||||
|
|
||||||
|
const unhealthyData = data.global.map(entry => {
|
||||||
|
return {x: moment.unix(entry.time), y: entry.dead};
|
||||||
|
});
|
||||||
|
|
||||||
|
let statsData = [];
|
||||||
|
let statsKeys = [];
|
||||||
|
if (data.stats.length > 0) {
|
||||||
|
|
||||||
|
statsKeys = Object.keys(data.stats[0]).filter(key => key !== 'time');
|
||||||
|
statsData = statsKeys.map(key => ({"key": key, data: data.stats.map(entry => ({x: moment.unix(entry.time), y: entry[key]}))}));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Extracting data for chart
|
|
||||||
const timestamps = data.global.map(entry => entry.time * 1000); // Converting UNIX timestamps to milliseconds
|
|
||||||
const healthyData = data.global.map(entry => entry.alive);
|
|
||||||
const unhealthyData = data.global.map(entry => entry.dead);
|
|
||||||
const userUnhealthyData = data.user.filter(entry => entry.dead > 0).map(entry => ({"x": entry.time * 1000, "y": entry.dead}));
|
const userUnhealthyData = data.user.filter(entry => entry.dead > 0).map(entry => ({"x": entry.time * 1000, "y": entry.dead}));
|
||||||
const paramData = {"global": {"healthyData": healthyData, "unhealthyData": unhealthyData}, "user": userUnhealthyData};
|
const paramData = {"healthyData": healthyData, "unhealthyData": unhealthyData, "user": userUnhealthyData, "stats": statsData, "statsKeys": statsKeys};
|
||||||
console.log(paramData);
|
|
||||||
createChart(timestamps, paramData);
|
|
||||||
|
|
||||||
|
createChartOverview(paramData);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createChart(timestamps, data) {
|
function mapKey(key) {
|
||||||
const ctx = document.getElementById('graph').getContext('2d');
|
const keyMappings = {
|
||||||
let high_data = timestamps.length > 1000;
|
"limited_accs_max": "Max Lim. Accs",
|
||||||
|
"limited_accs_avg": "Avg Lim. Accs",
|
||||||
|
"total_accs_max": "Max Accs",
|
||||||
|
"total_accs_avg": "Avg Accs",
|
||||||
|
"total_requests_max": "Max Requests Req.",
|
||||||
|
"total_requests_avg": "Avg Requests Req.",
|
||||||
|
"req_photo_rail_max": "Max PhotoRail Req.",
|
||||||
|
"req_photo_rail_avg": "Avg PhotoRail Req.",
|
||||||
|
"req_user_screen_name_max": "Max UserScreenName Req.",
|
||||||
|
"req_user_screen_name_avg": "Avg UserScreenName Req.",
|
||||||
|
"req_search_max": "Max Search Req.",
|
||||||
|
"req_search_avg": "Avg Search Req.",
|
||||||
|
"req_list_tweets_max": "Max ListTweets Req.",
|
||||||
|
"req_list_tweets_avg": "Avg ListTweets Req.",
|
||||||
|
"req_user_media_max": "Max UserMedia Req.",
|
||||||
|
"req_user_media_avg": "Avg UserMedia Req.",
|
||||||
|
"req_tweet_detail_max": "Max TweetDetail Req.",
|
||||||
|
"req_tweet_detail_avg": "Avg TweetDetail Req.",
|
||||||
|
"req_list_max": "Max List Req.",
|
||||||
|
"req_list_avg": "Avg List Req.",
|
||||||
|
"req_user_tweets_max": "Max UserTweets Req.",
|
||||||
|
"req_user_tweets_avg": "Avg UserTweets Req.",
|
||||||
|
"req_user_tweets_and_replies_max": "Max UserTweetsAndReplies Req.",
|
||||||
|
"req_user_tweets_and_replies_avg": "Avg UserTweetsAndReplies Req.",
|
||||||
|
}
|
||||||
|
return keyMappings[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
function nameToColor(name) {
|
||||||
|
const colors = ["#fafa6e"
|
||||||
|
,"#eafb71"
|
||||||
|
,"#dafb75"
|
||||||
|
,"#cafb7b"
|
||||||
|
,"#b9fa81"
|
||||||
|
,"#a8f989"
|
||||||
|
,"#97f890"
|
||||||
|
,"#85f799"
|
||||||
|
,"#71f5a1"
|
||||||
|
,"#5cf3aa"
|
||||||
|
,"#42f1b3"
|
||||||
|
,"#15efbc"
|
||||||
|
,"#00ecc4"
|
||||||
|
,"#00e9cd"
|
||||||
|
,"#00e6d5"
|
||||||
|
,"#00e3dd"
|
||||||
|
,"#00e0e4"
|
||||||
|
,"#00dcea"
|
||||||
|
,"#00d8f0"
|
||||||
|
,"#00d5f5"
|
||||||
|
,"#00d0f9"
|
||||||
|
,"#00ccfd"
|
||||||
|
,"#00c8ff"
|
||||||
|
,"#00c3ff"
|
||||||
|
,"#00beff"
|
||||||
|
,"#00b9ff"
|
||||||
|
,"#00b4ff"];
|
||||||
|
var hash = hashStr(name);
|
||||||
|
var index = hash % colors.length;
|
||||||
|
return colors[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashStr(str) {
|
||||||
|
var hash = 0;
|
||||||
|
for (var i = 0; i < str.length; i++) {
|
||||||
|
var charCode = str.charCodeAt(i);
|
||||||
|
hash += charCode;
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChartOverview(data) {
|
||||||
|
const ctx = document.getElementById('graph-health').getContext('2d');
|
||||||
|
let high_data = data.stats.length > 1000;
|
||||||
if (chart) {
|
if (chart) {
|
||||||
chart.destroy();
|
chart.destroy();
|
||||||
}
|
}
|
||||||
|
let datasets = [{
|
||||||
|
label: 'Healthy',
|
||||||
|
data: data.healthyData,
|
||||||
|
borderColor: 'green',
|
||||||
|
backgroundColor: 'rgba(0, 255, 0, 0.2)',
|
||||||
|
fill: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Unhealthy',
|
||||||
|
data: data.unhealthyData,
|
||||||
|
borderColor: 'orange',
|
||||||
|
backgroundColor: 'rgba(241, 90, 34, 0.2)',
|
||||||
|
fill: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Own Instances Unhealthy',
|
||||||
|
data: data.user,
|
||||||
|
pointRadius: 5,
|
||||||
|
pointBackgroundColor: 'rgba(255, 0, 0, 1)',
|
||||||
|
fill: false,
|
||||||
|
datasetIndex: "x",
|
||||||
|
}];
|
||||||
|
datasets = datasets.concat(data.stats.map(entry => ({
|
||||||
|
yAxisID: 'yStats',
|
||||||
|
label: mapKey(entry.key),
|
||||||
|
data: entry.data,
|
||||||
|
borderColor: nameToColor(entry.key),
|
||||||
|
fill: false,
|
||||||
|
hidden: (entry.key !== 'total_requests_avg' && entry.key !== 'limited_accs_avg'),
|
||||||
|
})));
|
||||||
|
console.log(datasets);
|
||||||
chart = new Chart(ctx, {
|
chart = new Chart(ctx, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
data: {
|
||||||
labels: timestamps,
|
datasets: datasets,
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
label: 'Healthy',
|
|
||||||
data: data.global.healthyData,
|
|
||||||
borderColor: 'green',
|
|
||||||
backgroundColor: 'rgba(0, 255, 0, 0.2)',
|
|
||||||
fill: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Unhealthy',
|
|
||||||
data: data.global.unhealthyData,
|
|
||||||
borderColor: 'orange',
|
|
||||||
backgroundColor: 'rgba(241, 90, 34, 0.2)',
|
|
||||||
fill: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Own Instances Unhealthy',
|
|
||||||
data: data.user,
|
|
||||||
pointRadius: 5,
|
|
||||||
pointBackgroundColor: 'rgba(255, 0, 0, 1)',
|
|
||||||
fill: false,
|
|
||||||
datasetIndex: "x",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
|
responsive: false,
|
||||||
|
maintainAspectRatio: false,
|
||||||
plugins: {
|
plugins: {
|
||||||
|
colors: {
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
decimation: {
|
decimation: {
|
||||||
enabled: high_data,
|
enabled: high_data,
|
||||||
algorithm: 'min-max',
|
algorithm: 'min-max',
|
||||||
},
|
},
|
||||||
|
tooltip: {
|
||||||
|
// callbacks: {
|
||||||
|
// label: function(context) {
|
||||||
|
// console.log(context);
|
||||||
|
// if (context.dataset) {
|
||||||
|
// if (context.dataset.yAxisID == 'yStats') {
|
||||||
|
// let dataset = context.dataset.data[context.dataIndex];
|
||||||
|
// if (!dataset) {
|
||||||
|
// console.warn(context.dataset);
|
||||||
|
// console.warn(context.dataset.data[context.dataIndex]);
|
||||||
|
// }
|
||||||
|
// let mainValue = dataset.y;
|
||||||
|
// let detailValue = dataset.additional.total_requests_max;
|
||||||
|
// return [`${context.dataset.label}: ${mainValue}`,`Max Total Requests: ${detailValue}`];
|
||||||
|
// } else {
|
||||||
|
// return `${context.dataset.label}: ${context.formattedValue}`;
|
||||||
|
// }
|
||||||
|
// } else {
|
||||||
|
// console.log(context);
|
||||||
|
// return null;
|
||||||
|
// }
|
||||||
|
// // let label = context.dataset.label || '';
|
||||||
|
|
||||||
|
// // if (label) {
|
||||||
|
// // label += ': ';
|
||||||
|
// // }
|
||||||
|
// // if (context.parsed.y !== null) {
|
||||||
|
// // label += new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(context.parsed.y);
|
||||||
|
// // }
|
||||||
|
// return 'asdf';
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
}
|
||||||
},
|
},
|
||||||
animation: !high_data,
|
animation: !high_data,
|
||||||
scales: {
|
scales: {
|
||||||
x: {
|
x: {
|
||||||
type: 'time',
|
type: 'time',
|
||||||
time: {
|
time: {
|
||||||
unit: 'day' // You might want to adjust this based on your data resolution
|
unit: 'day'
|
||||||
},
|
},
|
||||||
autoSkip: true,
|
autoSkip: true,
|
||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
beginAtZero: true
|
beginAtZero: true
|
||||||
|
},
|
||||||
|
yStats: {
|
||||||
|
beginAtZero: false
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false
|
||||||
|
},
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -25,6 +25,24 @@ document.addEventListener("DOMContentLoaded", function() {
|
|||||||
fetchDataAndCreateChart(initialStartDate,initialEndDate);
|
fetchDataAndCreateChart(initialStartDate,initialEndDate);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function mapKey(key) {
|
||||||
|
const keyMappings = {
|
||||||
|
"limited_accs": "Limited Accs",
|
||||||
|
"total_accs": "Total Accs",
|
||||||
|
"total_requests": "Total Requests",
|
||||||
|
"req_photo_rail": "PhotoRail Requests",
|
||||||
|
"req_user_screen_name": "UserScreeName Req.",
|
||||||
|
"req_search": "Search Requests",
|
||||||
|
"req_list_tweets": "ListTeweets Req.",
|
||||||
|
"req_user_media": "UserMedia Req.",
|
||||||
|
"req_tweet_detail": "TweetDetail Req.",
|
||||||
|
"req_list": "List Requests",
|
||||||
|
"req_user_tweets": "UserTweets Req.",
|
||||||
|
"req_user_tweets_and_replies": "UserTweetsAndReplies Req.",
|
||||||
|
}
|
||||||
|
return keyMappings[key];
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchDataAndCreateChart(startDate,endDate) {
|
async function fetchDataAndCreateChart(startDate,endDate) {
|
||||||
let jsonData;
|
let jsonData;
|
||||||
try {
|
try {
|
||||||
@@ -41,15 +59,14 @@ async function fetchDataAndCreateChart(startDate,endDate) {
|
|||||||
console.error('Failed to fetch data:', error);
|
console.error('Failed to fetch data:', error);
|
||||||
}
|
}
|
||||||
if (jsonData) {
|
if (jsonData) {
|
||||||
createChart(jsonData);
|
createChartOverview(jsonData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createChart(jsonData) {
|
function createChartOverview(jsonData) {
|
||||||
const timeLabels = jsonData.map(dataPoint => new Date(dataPoint.time * 1000));
|
const healthyData = jsonData.health.filter(entry => entry.healthy).map(entry => ({x: moment.unix(entry.time), y: entry.resp_time}));
|
||||||
console.log(timeLabels);
|
const unhealthyData = jsonData.health.filter(entry => !entry.healthy).map(entry => ({x: moment.unix(entry.time), y: entry.resp_time}));
|
||||||
const healthyData = jsonData.map(dataPoint => dataPoint.healthy ? (dataPoint.resp_time ? dataPoint.resp_time : 0) : null);
|
const statsData = jsonData.stats.map(entry => ({x: moment.unix(entry.time), y: entry.total_requests}));
|
||||||
const unhealthyData = jsonData.map(dataPoint => dataPoint.healthy ? null : (dataPoint.resp_time ? dataPoint.resp_time : 0));
|
|
||||||
|
|
||||||
const ctx = document.getElementById('graph').getContext('2d');
|
const ctx = document.getElementById('graph').getContext('2d');
|
||||||
if (chart) {
|
if (chart) {
|
||||||
@@ -58,23 +75,33 @@ function createChart(jsonData) {
|
|||||||
chart = new Chart(ctx, {
|
chart = new Chart(ctx, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
data: {
|
||||||
labels: timeLabels,
|
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: 'Healthy',
|
label: 'Healthy Response Time (ms)',
|
||||||
data: healthyData,
|
data: healthyData,
|
||||||
borderColor: 'green',
|
borderColor: 'green',
|
||||||
fill: false,
|
fill: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Unhealthy',
|
label: 'Unhealthy Response Time (ms)',
|
||||||
data: unhealthyData,
|
data: unhealthyData,
|
||||||
borderColor: 'red',
|
borderColor: 'red',
|
||||||
fill: false,
|
fill: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Total API Requests',
|
||||||
|
data: statsData,
|
||||||
|
borderColor: 'blue',
|
||||||
|
fill: false,
|
||||||
|
yAxisID: 'yStats',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false
|
||||||
|
},
|
||||||
scales: {
|
scales: {
|
||||||
x: {
|
x: {
|
||||||
type: 'time',
|
type: 'time',
|
||||||
@@ -91,6 +118,12 @@ function createChart(jsonData) {
|
|||||||
display: true,
|
display: true,
|
||||||
labelString: 'Response Time'
|
labelString: 'Response Time'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
yStats: {
|
||||||
|
scaleLabel: {
|
||||||
|
display: true,
|
||||||
|
labelString: 'Requests'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
<h4>Instances</h4>
|
<h4>Instances</h4>
|
||||||
{% if is_admin %}<p class="text-danger-emphasis">root mode</p>{% endif %}
|
{% if is_admin %}<p class="text-danger-emphasis">root mode</p>{% endif %}
|
||||||
<div><canvas id="graph" width="600" height="200"></canvas><noscript>History feature works only with javascript enabled.</noscript></div>
|
<div class="table-responsive"><canvas id="graph-health" class="table" height="400"></canvas><noscript>History feature works only with javascript enabled.</noscript></div>
|
||||||
<div class="container mt-5">
|
<div class="container mt-5">
|
||||||
<form>
|
<form>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|||||||
@@ -135,8 +135,12 @@ fn read_scanner_cfg() -> miette::Result<ScannerConfig> {
|
|||||||
let error_retention_per_host: usize = require_env_str("ERROR_RETENTION_PER_HOST")?
|
let error_retention_per_host: usize = require_env_str("ERROR_RETENTION_PER_HOST")?
|
||||||
.parse()
|
.parse()
|
||||||
.expect("CLEANUP_INTERVAL_S must be a number");
|
.expect("CLEANUP_INTERVAL_S must be a number");
|
||||||
|
let instance_stats_interval: u64 = require_env_str("STATS_INTERVAL_S")?
|
||||||
|
.parse()
|
||||||
|
.expect("STATS_INTERVAL_S must be a positive number");
|
||||||
|
|
||||||
Ok(Arc::new(entities::state::scanner::Config {
|
Ok(Arc::new(entities::state::scanner::Config {
|
||||||
|
instance_stats_interval: Duration::from_secs(instance_stats_interval),
|
||||||
list_fetch_interval: Duration::from_secs(instance_list_interval),
|
list_fetch_interval: Duration::from_secs(instance_list_interval),
|
||||||
instance_check_interval: Duration::from_secs(instance_ping_interval),
|
instance_check_interval: Duration::from_secs(instance_ping_interval),
|
||||||
instance_list_url: nitter_instancelist,
|
instance_list_url: nitter_instancelist,
|
||||||
|
|||||||
Reference in new issue
Block a user