mirror of
https://github.com/YGGverse/htcount.git
synced 2026-03-31 17:15:37 +00:00
initial commit
This commit is contained in:
parent
c8d4947382
commit
f56ebb6877
9 changed files with 291 additions and 0 deletions
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
custom: https://yggverse.github.io/#donate
|
||||||
27
.github/workflows/build.yml
vendored
Normal file
27
.github/workflows/build.yml
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
name: Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ "main" ]
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUSTFLAGS: -Dwarnings
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Run rustfmt
|
||||||
|
run: cargo fmt --all -- --check
|
||||||
|
- name: Run clippy
|
||||||
|
run: cargo clippy --all-targets
|
||||||
|
- name: Build
|
||||||
|
run: cargo build --verbose
|
||||||
|
- name: Run tests
|
||||||
|
run: cargo test --verbose
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/target
|
||||||
|
Cargo.lock
|
||||||
15
Cargo.toml
Normal file
15
Cargo.toml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
[package]
|
||||||
|
name = "htcount"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
license = "MIT"
|
||||||
|
readme = "README.md"
|
||||||
|
description = "Simple CLI/daemon tool for counting visitors using access.log and exporting totals in multiple formats, such as JSON or SVG badge"
|
||||||
|
keywords = ["access", "log", "counter", "badge", "stats"]
|
||||||
|
categories = ["network-programming"]
|
||||||
|
repository = "https://github.com/YGGverse/htcount"
|
||||||
|
# homepage = "https://yggverse.github.io"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0"
|
||||||
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
86
README.md
Normal file
86
README.md
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
# htcount
|
||||||
|
|
||||||
|

|
||||||
|
[](https://deps.rs/repo/github/YGGverse/htcount)
|
||||||
|
[](https://crates.io/crates/htcount)
|
||||||
|
|
||||||
|
Simple CLI/daemon tool for counting visitors using `access.log` and exporting totals in multiple formats, such as JSON or SVG badge
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Log format support
|
||||||
|
|
||||||
|
* [x] Nginx
|
||||||
|
* [ ] Apache
|
||||||
|
|
||||||
|
### Export formats
|
||||||
|
|
||||||
|
* [x] JSON - for API usage
|
||||||
|
* [x] SVG - configurable badge button
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
1. `git clone https://github.com/YGGverse/htcount.git && cd htcount`
|
||||||
|
2. `cargo build --release`
|
||||||
|
3. `sudo install target/release/htcount /usr/local/bin/htcount`
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
htcount --source /var/log/nginx/access.log\
|
||||||
|
--export-json /path/to/totals.json\
|
||||||
|
--export-svg /path/to/totals.svg
|
||||||
|
```
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
-d, --debug <DEBUG>
|
||||||
|
Debug level
|
||||||
|
|
||||||
|
* `i` - info * `d` - detailed
|
||||||
|
|
||||||
|
[default: i]
|
||||||
|
|
||||||
|
-f, --format <FORMAT>
|
||||||
|
Log format for given `source`
|
||||||
|
|
||||||
|
* `nginx`
|
||||||
|
|
||||||
|
[default: nginx]
|
||||||
|
|
||||||
|
--export-json <EXPORT_JSON>
|
||||||
|
Export results to JSON file (e.g. `/path/to/stats.json`)
|
||||||
|
|
||||||
|
--export-svg <EXPORT_SVG>
|
||||||
|
Export results to SVG file (e.g. `/path/to/badge.svg`)
|
||||||
|
|
||||||
|
* use `{hits}` / `{hosts}` pattern to replace parsed values
|
||||||
|
|
||||||
|
--template-svg <TEMPLATE_SVG>
|
||||||
|
Use custom SVG file template with `{hits}` / `{hosts}` placeholders
|
||||||
|
|
||||||
|
[default: default/counter.svg]
|
||||||
|
|
||||||
|
-c, --capacity <CAPACITY>
|
||||||
|
Expected memory index capacity
|
||||||
|
|
||||||
|
[default: 100]
|
||||||
|
|
||||||
|
-i, --ignore-host <IGNORE_HOST>
|
||||||
|
Exclude host(s) from index
|
||||||
|
|
||||||
|
-s, --source <SOURCE>
|
||||||
|
Access log source (e.g. `/var/nginx/access.log`)
|
||||||
|
|
||||||
|
-u, --update <UPDATE>
|
||||||
|
Update delay in seconds
|
||||||
|
|
||||||
|
[default: 300]
|
||||||
|
|
||||||
|
-h, --help
|
||||||
|
Print help (see a summary with '-h')
|
||||||
|
|
||||||
|
-V, --version
|
||||||
|
Print version
|
||||||
|
```
|
||||||
8
default/counter.svg
Normal file
8
default/counter.svg
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<svg width="88" height="31" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<text x="8" y="12" font-family="monospace" font-size="10" fill="#000">
|
||||||
|
hosts: {hosts}
|
||||||
|
</text>
|
||||||
|
<text x="8" y="28" font-family="monospace" font-size="10" fill="#000">
|
||||||
|
hits: {hits}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 272 B |
48
src/argument.rs
Normal file
48
src/argument.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(version, about, long_about = None)]
|
||||||
|
pub struct Argument {
|
||||||
|
/// Debug level
|
||||||
|
///
|
||||||
|
/// * `i` - info
|
||||||
|
/// * `d` - detailed
|
||||||
|
#[arg(short, long, default_value_t = String::from("i"))]
|
||||||
|
pub debug: String,
|
||||||
|
|
||||||
|
/// Log format for given `source`
|
||||||
|
///
|
||||||
|
/// * `nginx`
|
||||||
|
#[arg(short, long, default_value_t = String::from("nginx"))]
|
||||||
|
pub format: String,
|
||||||
|
|
||||||
|
/// Export results to JSON file (e.g. `/path/to/stats.json`)
|
||||||
|
#[arg(long)]
|
||||||
|
pub export_json: Option<String>,
|
||||||
|
|
||||||
|
/// Export results to SVG file (e.g. `/path/to/badge.svg`)
|
||||||
|
///
|
||||||
|
/// * use `{hits}` / `{hosts}` pattern to replace parsed values
|
||||||
|
#[arg(long)]
|
||||||
|
pub export_svg: Option<String>,
|
||||||
|
|
||||||
|
/// Use custom SVG file template with `{hits}` / `{hosts}` placeholders
|
||||||
|
#[arg(long, default_value_t = String::from("default/counter.svg"))]
|
||||||
|
pub template_svg: String,
|
||||||
|
|
||||||
|
/// Expected memory index capacity
|
||||||
|
#[arg(short, long, default_value_t = 100)]
|
||||||
|
pub capacity: usize,
|
||||||
|
|
||||||
|
/// Exclude host(s) from index
|
||||||
|
#[arg(short, long)]
|
||||||
|
pub ignore_host: Vec<String>,
|
||||||
|
|
||||||
|
/// Access log source (e.g. `/var/nginx/access.log`)
|
||||||
|
#[arg(short, long)]
|
||||||
|
pub source: String,
|
||||||
|
|
||||||
|
/// Update delay in seconds
|
||||||
|
#[arg(short, long, default_value_t = 300)]
|
||||||
|
pub update: u64,
|
||||||
|
}
|
||||||
10
src/debug.rs
Normal file
10
src/debug.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
pub fn info(message: String) {
|
||||||
|
println!("[{}] [info] {message}", now())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now() -> u128 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_millis()
|
||||||
|
}
|
||||||
94
src/main.rs
Normal file
94
src/main.rs
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
mod argument;
|
||||||
|
mod debug;
|
||||||
|
|
||||||
|
fn main() -> anyhow::Result<()> {
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
fs::File,
|
||||||
|
io::{BufRead, BufReader, Write},
|
||||||
|
thread,
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
let argument = {
|
||||||
|
use clap::Parser;
|
||||||
|
argument::Argument::parse()
|
||||||
|
};
|
||||||
|
|
||||||
|
// calculate debug level once
|
||||||
|
let is_debug_i = argument.debug.contains("i");
|
||||||
|
let is_debug_d = argument.debug.contains("d");
|
||||||
|
|
||||||
|
if !matches!(argument.format.to_lowercase().as_str(), "nginx") {
|
||||||
|
todo!("Format `{}` yet not supported!", argument.format)
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_debug_i {
|
||||||
|
debug::info("Crawler started".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if is_debug_i {
|
||||||
|
debug::info("Index queue begin...".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let file = File::open(&argument.source)?;
|
||||||
|
let reader = BufReader::new(file);
|
||||||
|
|
||||||
|
let mut index: HashMap<String, usize> = HashMap::with_capacity(argument.capacity);
|
||||||
|
|
||||||
|
for line in reader.lines() {
|
||||||
|
let host = line?
|
||||||
|
.split_whitespace()
|
||||||
|
.next()
|
||||||
|
.map(|s| s.into())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if argument.ignore_host.contains(&host) {
|
||||||
|
if is_debug_d {
|
||||||
|
debug::info(format!("Host `{host}` ignored by settings"))
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
index.entry(host).and_modify(|c| *c += 1).or_insert(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let hosts = index.len();
|
||||||
|
let hits: usize = index.values().sum();
|
||||||
|
|
||||||
|
if is_debug_i {
|
||||||
|
debug::info(format!(
|
||||||
|
"Index queue completed:\n{}\n\thosts: {} / hits: {}, await {} seconds to continue...",
|
||||||
|
if is_debug_d {
|
||||||
|
let mut b = Vec::with_capacity(hosts);
|
||||||
|
for (host, count) in &index {
|
||||||
|
b.push(format!("\t{} ({})", host, count))
|
||||||
|
}
|
||||||
|
b.join("\n")
|
||||||
|
} else {
|
||||||
|
"".into()
|
||||||
|
},
|
||||||
|
hosts,
|
||||||
|
hits,
|
||||||
|
argument.update,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref p) = argument.export_json {
|
||||||
|
let mut f = File::create(p)?;
|
||||||
|
f.write_all(format!("{{\"hosts\":{hosts},\"hits\":{hits}}}").as_bytes())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref p) = argument.export_svg {
|
||||||
|
let t = std::fs::read_to_string(&argument.template_svg)?;
|
||||||
|
let mut f = File::create(p)?;
|
||||||
|
f.write_all(
|
||||||
|
t.replace("{hosts}", &hosts.to_string())
|
||||||
|
.replace("{hits}", &hits.to_string())
|
||||||
|
.as_bytes(),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
thread::sleep(Duration::from_secs(argument.update));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue