initial commit

This commit is contained in:
yggverse 2025-06-08 16:27:41 +03:00
parent c8d4947382
commit f56ebb6877
9 changed files with 291 additions and 0 deletions

1
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1 @@
custom: https://yggverse.github.io/#donate

27
.github/workflows/build.yml vendored Normal file
View 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
View file

@ -0,0 +1,2 @@
/target
Cargo.lock

15
Cargo.toml Normal file
View 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
View file

@ -0,0 +1,86 @@
# htcount
![Build](https://github.com/YGGverse/htcount/actions/workflows/build.yml/badge.svg)
[![Dependencies](https://deps.rs/repo/github/YGGverse/htcount/status.svg)](https://deps.rs/repo/github/YGGverse/htcount)
[![crates.io](https://img.shields.io/crates/v/htcount.svg)](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
View 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
View 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
View 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
View 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));
}
}