diff --git a/molenda.net/.gitea/workflows/deploy.yml b/molenda.net/.gitea/workflows/deploy.yml
new file mode 100644
index 0000000..7fcdb06
--- /dev/null
+++ b/molenda.net/.gitea/workflows/deploy.yml
@@ -0,0 +1,74 @@
+name: Deploy on merge to main
+
+on:
+ push:
+ branches:
+ - main
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+
+ steps:
+ # ── 1. Checkout ──────────────────────────────────────────────────────────
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ # ── 2. PHP + Composer ────────────────────────────────────────────────────
+ - name: Set up PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.3'
+ extensions: pdo, pdo_sqlite, sqlite3
+
+ - name: Install Composer dependencies (production only)
+ run: composer install --no-dev --optimize-autoloader
+
+ # ── 3. Pobierz aktualną bazę SQLite z serwera ────────────────────────────
+ # data.sqlite3 z repo NIE trafia na serwer – ściągamy produkcyjną wersję,
+ # uruchamiamy na niej migracje i wgrywamy z powrotem.
+ - name: Install lftp
+ run: sudo apt-get install -y lftp
+
+ - name: Download production data.sqlite3 via FTP
+ run: |
+ lftp -u "${{ secrets.FTP_USER }}","${{ secrets.FTP_PASS }}" \
+ -e "set ftp:ssl-allow no; \
+ set ssl:verify-certificate no; \
+ get ${{ secrets.FTP_REMOTE_DIR }}/data.sqlite3 -o ./data.sqlite3; \
+ quit" \
+ ${{ secrets.FTP_HOST }}
+
+ # ── 4. Uruchom migracje Phinx na pobranej bazie ──────────────────────────
+ # phinx.php: 'name' => __DIR__ . '/data' → plik data.sqlite3
+ - name: Run Phinx migrations
+ run: ./vendor/bin/phinx migrate -e main
+
+ # ── 5. Wgraj pliki na serwer (z zaktualizowaną bazą) ────────────────────
+ - name: Deploy files to server via FTP
+ uses: SamKirkland/FTP-Deploy-Action@v4.3.5
+ with:
+ server: ${{ secrets.FTP_HOST }}
+ username: ${{ secrets.FTP_USER }}
+ password: ${{ secrets.FTP_PASS }}
+ local-dir: ./
+ server-dir: ${{ secrets.FTP_REMOTE_DIR }}/
+ exclude: |
+ **/.git/**
+ **/.gitea/**
+ **/tests/**
+ **/docker/**
+ **/html_template/**
+ **/lib/**
+ **/var/cache/**
+ **/logs/**
+ docker-compose*.yml
+ Dockerfile
+ phpunit.xml
+ phpcs.xml
+ phpstan.neon.dist
+ README.md
+ CONTRIBUTING.md
+ .env*
+ local.data.sqlite3
+ server.data.sqlite3
diff --git a/molenda.net/.gitignore b/molenda.net/.gitignore
index 63066a6..991a4bb 100644
--- a/molenda.net/.gitignore
+++ b/molenda.net/.gitignore
@@ -5,3 +5,4 @@
/logs/*
!/logs/README.md
.phpunit.result.cache
+data.sqlite3
diff --git a/molenda.net/Dockerfile b/molenda.net/Dockerfile
new file mode 100644
index 0000000..bf6a5da
--- /dev/null
+++ b/molenda.net/Dockerfile
@@ -0,0 +1,27 @@
+FROM php:8.3-fpm
+
+WORKDIR /var/www
+
+# Install system dependencies and PHP extensions
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ git \
+ unzip \
+ zip \
+ libzip-dev \
+ sqlite3 \
+ libsqlite3-dev \
+ libonig-dev \
+ && docker-php-ext-configure zip \
+ && docker-php-ext-install -j$(nproc) pdo pdo_sqlite zip \
+ && rm -rf /var/lib/apt/lists/*
+
+# Install Composer from the official image
+COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
+
+# Ensure www-data owns the working dir
+RUN chown -R www-data:www-data /var/www || true
+
+EXPOSE 9000
+
+CMD ["php-fpm"]
diff --git a/molenda.net/app/routes.php b/molenda.net/app/routes.php
index 53ff97d..0a94962 100644
--- a/molenda.net/app/routes.php
+++ b/molenda.net/app/routes.php
@@ -35,6 +35,7 @@ return function (App $app) {
// Protected admin area
$app->group('/admin', function (Group $group) {
$group->get('', \App\Application\Actions\Admin\DashboardAction::class);
+ $group->get('/blog-visits', \App\Application\Actions\Admin\BlogVisitsAction::class);
$group->get('/contents', \App\Application\Actions\Content\ContentCrudAction::class . ':list');
$group->get('/access-logs', \App\Application\Actions\Admin\AccessLogsAction::class);
$group->map(['GET', 'POST'], '/contents/create', \App\Application\Actions\Content\ContentCrudAction::class . ':create');
diff --git a/molenda.net/data.sqlite3 b/molenda.net/data.sqlite3
index e8dd3e0..f93f83d 100644
Binary files a/molenda.net/data.sqlite3 and b/molenda.net/data.sqlite3 differ
diff --git a/molenda.net/db/migrations/20260513000000_create_blog_visits.php b/molenda.net/db/migrations/20260513000000_create_blog_visits.php
new file mode 100644
index 0000000..f01d8a3
--- /dev/null
+++ b/molenda.net/db/migrations/20260513000000_create_blog_visits.php
@@ -0,0 +1,23 @@
+table('blog_visits');
+ if (!$table->exists()) {
+ $table->addColumn('ip', 'string', ['null' => true, 'limit' => 255])
+ ->addColumn('useragent', 'text', ['null' => true])
+ ->addColumn('cnt', 'integer', ['default' => 0])
+ ->addColumn('first_seen', 'integer', ['null' => true])
+ ->addColumn('last_seen', 'integer', ['null' => true])
+ ->addIndex(['ip', 'useragent'], ['unique' => true, 'name' => 'idx_ip_useragent'])
+ ->create();
+ }
+ }
+}
diff --git a/molenda.net/docker-compose.dev.yml b/molenda.net/docker-compose.dev.yml
new file mode 100644
index 0000000..0bf33d8
--- /dev/null
+++ b/molenda.net/docker-compose.dev.yml
@@ -0,0 +1,36 @@
+version: '3.8'
+
+services:
+ php:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ working_dir: /var/www
+ volumes:
+ - ./:/var/www:cached
+ - ./docker/php/memory.ini:/usr/local/etc/php/conf.d/memory.ini:ro
+ environment:
+ COMPOSER_ALLOW_SUPERUSER: "1"
+ mem_limit: 1G
+ deploy:
+ resources:
+ limits:
+ memory: 1G
+ networks:
+ - appnet
+
+ nginx:
+ image: nginx:1.25-alpine
+ ports:
+ - "8080:80"
+ volumes:
+ - ./:/var/www:ro
+ - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
+ depends_on:
+ - php
+ networks:
+ - appnet
+
+networks:
+ appnet:
+ driver: bridge
diff --git a/molenda.net/docker/nginx/default.conf b/molenda.net/docker/nginx/default.conf
new file mode 100644
index 0000000..415833b
--- /dev/null
+++ b/molenda.net/docker/nginx/default.conf
@@ -0,0 +1,25 @@
+server {
+ listen 80;
+ server_name _;
+ root /var/www/public;
+ index index.php index.html;
+
+ access_log /var/log/nginx/access.log;
+ error_log /var/log/nginx/error.log;
+
+ location / {
+ try_files $uri $uri/ /index.php?$query_string;
+ }
+
+ location ~ \.php$ {
+ include fastcgi_params;
+ fastcgi_pass php:9000;
+ fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
+ fastcgi_index index.php;
+ }
+
+ location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
+ expires max;
+ log_not_found off;
+ }
+}
diff --git a/molenda.net/docker/php/memory.ini b/molenda.net/docker/php/memory.ini
new file mode 100644
index 0000000..a6a203a
--- /dev/null
+++ b/molenda.net/docker/php/memory.ini
@@ -0,0 +1 @@
+memory_limit = 1G
diff --git a/molenda.net/public/info.php b/molenda.net/public/info.php
new file mode 100644
index 0000000..61ace19
--- /dev/null
+++ b/molenda.net/public/info.php
@@ -0,0 +1,2 @@
+pdo = $pdo;
+ $this->twig = $twig;
+ }
+
+ public function __invoke(Request $request, Response $response, array $args): Response
+ {
+ // get top aggregates
+ $stmt = $this->pdo->query('SELECT ip, useragent, cnt, datetime(first_seen, "unixepoch") AS first_seen, datetime(last_seen, "unixepoch") AS last_seen FROM blog_visits ORDER BY cnt DESC LIMIT 100');
+ $visitors = $stmt->fetchAll(PDO::FETCH_ASSOC);
+
+ // read last 200 lines from detail log (if exists)
+ $detailPath = dirname(__DIR__, 3) . '/logs/blog_detail.log';
+ $recentDetails = [];
+ if (is_readable($detailPath)) {
+ $lines = array_filter(array_map('rtrim', file($detailPath)));
+ $lines = array_slice($lines, -200);
+ foreach ($lines as $line) {
+ // format: timestamp \t ip \t url
+ $parts = preg_split('/\t+/', $line);
+ $recentDetails[] = [
+ 'timestamp' => $parts[0] ?? '',
+ 'ip' => $parts[1] ?? '',
+ 'url' => $parts[2] ?? ''
+ ];
+ }
+ }
+
+ $html = $this->twig->render('admin/blog_visits.twig', ['visitors' => $visitors, 'details' => $recentDetails]);
+ $response->getBody()->write($html);
+ return $response->withHeader('Content-Type', 'text/html');
+ }
+}
diff --git a/molenda.net/src/Application/Actions/BlogIndexAction.php b/molenda.net/src/Application/Actions/BlogIndexAction.php
index 1a0c364..9efb1d0 100644
--- a/molenda.net/src/Application/Actions/BlogIndexAction.php
+++ b/molenda.net/src/Application/Actions/BlogIndexAction.php
@@ -32,8 +32,64 @@ class BlogIndexAction
$row = $stmt->fetch(PDO::FETCH_ASSOC);
$links = $row ? json_decode($row['content'], true) : [];
+ // log this blog page visit (aggregate + detailed)
+ try {
+ $this->logBlogVisit($request);
+ } catch (\Throwable $e) {
+ // ignore logging errors
+ }
+
$html = $this->twig->render('blog/index.twig', ['articles' => $articles, 'links' => $links]);
$response->getBody()->write($html);
return $response->withHeader('Content-Type', 'text/html');
}
+
+ private function logBlogVisit(Request $request): void
+ {
+ // detailed file log: timestamp, ip, url
+ $server = $request->getServerParams();
+ $xff = $request->getHeaderLine('X-Forwarded-For');
+ $ip = trim(explode(',', $xff)[0] ?? ($server['REMOTE_ADDR'] ?? '')) ?: 'unknown';
+ $userAgent = $request->getHeaderLine('User-Agent') ?: '';
+ $url = (string)$request->getUri();
+
+ $logDir = dirname(__DIR__, 3) . '/logs';
+ $detailPath = $logDir . '/blog_detail.log';
+
+ // append detail line
+ @file_put_contents($detailPath, sprintf("%s\t%s\t%s\n", date('Y-m-d H:i:s'), $ip, $url), FILE_APPEND | LOCK_EX);
+
+ // update aggregate counts in SQLite table
+ $now = time();
+ // create table if needed and safely increment count
+ $this->pdo->beginTransaction();
+ try {
+ $this->pdo->exec("CREATE TABLE IF NOT EXISTS blog_visits (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ ip TEXT,
+ useragent TEXT,
+ cnt INTEGER DEFAULT 0,
+ first_seen INTEGER,
+ last_seen INTEGER,
+ UNIQUE(ip, useragent)
+ )");
+
+ $select = $this->pdo->prepare('SELECT id, cnt FROM blog_visits WHERE ip = :ip AND useragent = :ua');
+ $select->execute([':ip' => $ip, ':ua' => $userAgent]);
+ $row = $select->fetch(PDO::FETCH_ASSOC);
+
+ if ($row) {
+ $update = $this->pdo->prepare('UPDATE blog_visits SET cnt = cnt + 1, last_seen = :last WHERE id = :id');
+ $update->execute([':last' => $now, ':id' => $row['id']]);
+ } else {
+ $insert = $this->pdo->prepare('INSERT INTO blog_visits (ip, useragent, cnt, first_seen, last_seen) VALUES (:ip, :ua, 1, :t, :t)');
+ $insert->execute([':ip' => $ip, ':ua' => $userAgent, ':t' => $now]);
+ }
+
+ $this->pdo->commit();
+ } catch (\Throwable $e) {
+ $this->pdo->rollBack();
+ throw $e;
+ }
+ }
}
diff --git a/molenda.net/src/Application/Actions/BlogPostAction.php b/molenda.net/src/Application/Actions/BlogPostAction.php
index 2c3d5dd..e19f4c1 100644
--- a/molenda.net/src/Application/Actions/BlogPostAction.php
+++ b/molenda.net/src/Application/Actions/BlogPostAction.php
@@ -25,12 +25,32 @@ class BlogPostAction
public function __invoke(Request $request, Response $response, array $args): Response
{
+ $start = microtime(true);
+
$stub = $args['stub'];
$article = $this->blogProvider->getByStub($stub);
if (!$article) {
$html = $this->twig->render('404.twig');
$response->getBody()->write($html);
+
+ // log timing for failed request as well
+ try {
+ $end = microtime(true);
+ $us = (int)(($end - $start) * 1_000_000);
+ $url = (string)$request->getUri();
+ $logPath = dirname(__DIR__, 3) . '/logs/times.log';
+ @file_put_contents($logPath, sprintf("%d %s\n", $us, $url), FILE_APPEND | LOCK_EX);
+ // blog visit logging for not found post
+ try {
+ $this->logBlogVisit($request);
+ } catch (\Throwable $e) {
+ // ignore logging errors
+ }
+ } catch (\Throwable $e) {
+ // ignore logging errors
+ }
+
return $response->withStatus(404)->withHeader('Content-Type', 'text/html');
}
@@ -43,6 +63,69 @@ class BlogPostAction
$html = $this->twig->render('blog/post.twig', ['article' => $article, 'links' => $links, 'alsoReading' => $alsoReading]);
$response->getBody()->write($html);
+
+ // timing end and log
+ try {
+ $end = microtime(true);
+ $us = (int)(($end - $start) * 1_000_000);
+ $url = (string)$request->getUri();
+ $logPath = dirname(__DIR__, 3) . '/logs/times.log';
+ @file_put_contents($logPath, sprintf("%d %s\n", $us, $url), FILE_APPEND | LOCK_EX);
+ // blog visit logging for successful post
+ try {
+ $this->logBlogVisit($request);
+ } catch (\Throwable $e) {
+ // ignore logging errors
+ }
+ } catch (\Throwable $e) {
+ // ignore logging errors
+ }
+
return $response->withHeader('Content-Type', 'text/html');
}
+
+ private function logBlogVisit(Request $request): void
+ {
+ $server = $request->getServerParams();
+ $xff = $request->getHeaderLine('X-Forwarded-For');
+ $ip = trim(explode(',', $xff)[0] ?? ($server['REMOTE_ADDR'] ?? '')) ?: 'unknown';
+ $userAgent = $request->getHeaderLine('User-Agent') ?: '';
+ $url = (string)$request->getUri();
+
+ $logDir = dirname(__DIR__, 3) . '/logs';
+ $detailPath = $logDir . '/blog_detail.log';
+
+ @file_put_contents($detailPath, sprintf("%s\t%s\t%s\n", date('Y-m-d H:i:s'), $ip, $url), FILE_APPEND | LOCK_EX);
+
+ $now = time();
+ $this->pdo->beginTransaction();
+ try {
+ $this->pdo->exec("CREATE TABLE IF NOT EXISTS blog_visits (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ ip TEXT,
+ useragent TEXT,
+ cnt INTEGER DEFAULT 0,
+ first_seen INTEGER,
+ last_seen INTEGER,
+ UNIQUE(ip, useragent)
+ )");
+
+ $select = $this->pdo->prepare('SELECT id, cnt FROM blog_visits WHERE ip = :ip AND useragent = :ua');
+ $select->execute([':ip' => $ip, ':ua' => $userAgent]);
+ $row = $select->fetch(PDO::FETCH_ASSOC);
+
+ if ($row) {
+ $update = $this->pdo->prepare('UPDATE blog_visits SET cnt = cnt + 1, last_seen = :last WHERE id = :id');
+ $update->execute([':last' => $now, ':id' => $row['id']]);
+ } else {
+ $insert = $this->pdo->prepare('INSERT INTO blog_visits (ip, useragent, cnt, first_seen, last_seen) VALUES (:ip, :ua, 1, :t, :t)');
+ $insert->execute([':ip' => $ip, ':ua' => $userAgent, ':t' => $now]);
+ }
+
+ $this->pdo->commit();
+ } catch (\Throwable $e) {
+ $this->pdo->rollBack();
+ throw $e;
+ }
+ }
}
diff --git a/molenda.net/src/Application/Services/BlogProvider.php b/molenda.net/src/Application/Services/BlogProvider.php
index c5f90fb..e539c04 100644
--- a/molenda.net/src/Application/Services/BlogProvider.php
+++ b/molenda.net/src/Application/Services/BlogProvider.php
@@ -6,61 +6,104 @@ namespace App\Application\Services;
class BlogProvider
{
+ private array $tableCache = [];
+ private string $cacheDir;
+
+ public function __construct()
+ {
+ $this->cacheDir = dirname(__DIR__, 3) . '/var/cache/markov';
+ if (!is_dir($this->cacheDir)) {
+ @mkdir($this->cacheDir, 0775, true);
+ }
+ }
+
private function generate_markov_table($text, $look_forward = 4)
{
- $table = array();
+ // build counts in a single pass and compute cumulative weights for fast selection
+ $len = strlen($text);
+ $table = [];
- // now walk through the text and make the index table
- for ($i = 0; $i < strlen($text); $i++) {
- $char = substr($text, $i, $look_forward);
- if (!isset($table[$char])) $table[$char] = array();
- }
-
- // walk the array again and count the numbers
- for ($i = 0; $i < (strlen($text) - $look_forward); $i++) {
- $char_index = substr($text, $i, $look_forward);
- $char_count = substr($text, $i+$look_forward, $look_forward);
-
- if (isset($table[$char_index][$char_count])) {
- $table[$char_index][$char_count]++;
+ // single pass to build counts
+ $max = $len - $look_forward;
+ for ($i = 0; $i < $max; $i++) {
+ $key = substr($text, $i, $look_forward);
+ $next = substr($text, $i + $look_forward, $look_forward);
+ if (!isset($table[$key])) $table[$key] = [];
+ if (isset($table[$key][$next])) {
+ $table[$key][$next]++;
} else {
- $table[$char_index][$char_count] = 1;
+ $table[$key][$next] = 1;
}
}
+ // convert counts into cumulative arrays with totals for fast selection
+ // to save memory we map next-token strings to integer ids and keep a shared token list
+ $tokenIndex = [];
+ $tokenList = [];
+ $nextId = 0;
+
+ foreach ($table as $key => $counts) {
+ $cum = [];
+ $sum = 0;
+ foreach ($counts as $item => $weight) {
+ if (!isset($tokenIndex[$item])) {
+ $tokenIndex[$item] = $nextId;
+ $tokenList[$nextId] = $item;
+ $nextId++;
+ }
+ $id = $tokenIndex[$item];
+ $sum += $weight;
+ $cum[] = ['id' => $id, 'cum' => $sum];
+ }
+ $table[$key] = ['total' => $sum, 'cum' => $cum];
+ }
+
+ // attach token list for this table so picker can map ids back to strings
+ $table['_tokens'] = $tokenList;
+
return $table;
}
private function generate_markov_text($length, $table, $look_forward = 4)
{
- // get first character
- $char = array_rand($table);
+ // pick a random starting state
+ $states = array_keys($table);
+ if (!$states) return '';
+ $char = $states[array_rand($states)];
$o = $char;
- for ($i = 0; $i < ($length / $look_forward); $i++) {
- $newchar = $this->return_weighted_char($table[$char]);
-
- if ($newchar) {
- $char = $newchar;
- $o .= $newchar;
- } else {
- $char = array_rand($table);
+ $iterations = (int)ceil($length / max(1, $look_forward));
+ for ($i = 0; $i < $iterations; $i++) {
+ $entry = $table[$char] ?? null;
+ if ($entry && !empty($entry['cum'])) {
+ $newId = $this->pick_weighted($entry);
+ if ($newId !== false) {
+ $tokenList = $table['_tokens'] ?? [];
+ $newchar = $tokenList[$newId] ?? false;
+ if ($newchar !== false) {
+ $char = $newchar;
+ $o .= $newchar;
+ continue;
+ }
+ }
}
+ // fallback: pick another random state
+ $char = $states[array_rand($states)];
+ $o .= $char;
}
return $o;
}
- private function return_weighted_char($array)
+ private function pick_weighted(array $entry)
{
- if (!$array) return false;
-
- $total = array_sum($array);
- $rand = mt_rand(1, $total);
- foreach ($array as $item => $weight) {
- if ($rand <= $weight) return $item;
- $rand -= $weight;
+ // entry contains 'total' and 'cum' keys
+ if (empty($entry['cum']) || empty($entry['total'])) return false;
+ $rand = mt_rand(1, $entry['total']);
+ foreach ($entry['cum'] as $pair) {
+ if ($rand <= $pair['cum']) return $pair['id'];
}
+ return false;
}
private function generate($length)
@@ -69,9 +112,8 @@ class BlogProvider
if (!is_readable($path)) {
throw new \RuntimeException("Missing data file: {$path}");
}
- $text = file_get_contents($path);
- $table = $this->generate_markov_table($text);
+ $table = $this->getTableForFile($path);
return $this->generate_markov_text($length, $table);
}
@@ -81,10 +123,39 @@ class BlogProvider
if (!is_readable($path)) {
throw new \RuntimeException("Missing data file: {$path}");
}
- $text = file_get_contents($path);
- $table = $this->generate_markov_table($text);
- return $this->generate_markov_text($length, $table);
+ $table = $this->getTableForFile($path);
+ return $this->generate_markov_text($length, $table, 4);
+ }
+
+ private function getTableForFile(string $path, int $look_forward = 4): array
+ {
+ $cacheKey = md5($path . '|' . $look_forward);
+ if (isset($this->tableCache[$cacheKey])) return $this->tableCache[$cacheKey];
+
+ $cacheFile = $this->cacheDir . '/markov_' . $cacheKey . '.ser';
+ $srcMTime = filemtime($path) ?: 0;
+
+ if (is_readable($cacheFile)) {
+ $data = @unserialize(@file_get_contents($cacheFile));
+ if (is_array($data) && isset($data['mtime']) && $data['mtime'] === $srcMTime && isset($data['table'])) {
+ $this->tableCache[$cacheKey] = $data['table'];
+ return $data['table'];
+ }
+ }
+
+ $text = file_get_contents($path);
+ $table = $this->generate_markov_table($text, $look_forward);
+
+ // write cache best-effort
+ try {
+ @file_put_contents($cacheFile, serialize(['mtime' => $srcMTime, 'table' => $table]), LOCK_EX);
+ } catch (\Throwable $e) {
+ // ignore
+ }
+
+ $this->tableCache[$cacheKey] = $table;
+ return $table;
}
private function stubify(string $title): string
diff --git a/molenda.net/templates/admin/blog_visits.twig b/molenda.net/templates/admin/blog_visits.twig
new file mode 100644
index 0000000..1fc9f00
--- /dev/null
+++ b/molenda.net/templates/admin/blog_visits.twig
@@ -0,0 +1,44 @@
+{% extends 'layout.twig' %}
+
+{% block title %}Blog Visits - Admin{% endblock %}
+
+{% block content %}
+
Blog Visits
+
+ Top visitors (by hits)
+
+
+ | IP | User-Agent | Count | First seen | Last seen |
+
+
+ {% for v in visitors %}
+
+ | {{ v.ip }} |
+ {{ v.useragent }} |
+ {{ v.cnt }} |
+ {{ v.first_seen }} |
+ {{ v.last_seen }} |
+
+ {% else %}
+ | No visitors yet |
+ {% endfor %}
+
+
+
+ Recent detail log
+
+ | Timestamp | IP | URL |
+
+ {% for d in details %}
+
+ | {{ d.timestamp }} |
+ {{ d.ip }} |
+ {{ d.url }} |
+
+ {% else %}
+ | No details |
+ {% endfor %}
+
+
+
+{% endblock %}