Compare commits
11 Commits
bd9c5b84be
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d65c8b009c | ||
|
|
86b98e8c16 | ||
|
|
2bc1b780b8 | ||
|
|
306db9bc1d | ||
|
|
70219c9dd0 | ||
|
|
03a9dcff22 | ||
|
|
caaa314065 | ||
|
|
0e7cf5d5cf | ||
|
|
bc0841c3c0 | ||
|
|
0323212d9d | ||
|
|
74669f15f7 |
@@ -7,7 +7,3 @@ BUILD_NUMBER=prod-v1.0
|
||||
|
||||
# Grafana settings
|
||||
GRAFANA_PASSWORD=admin
|
||||
|
||||
# Optional: Custom Prometheus retention
|
||||
# PROMETHEUS_RETENTION_TIME=30d
|
||||
# PROMETHEUS_RETENTION_SIZE=10GB
|
||||
|
||||
15
.github/workflows/docker-build-self.yml
vendored
15
.github/workflows/docker-build-self.yml
vendored
@@ -17,6 +17,19 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: '1.25'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
go mod download
|
||||
go mod tidy
|
||||
- name: Build Go application
|
||||
run: |
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -v -o ./server ./src
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
docker build -t "$IMAGE_NAME:latest" -t "$IMAGE_NAME:${{ github.run_number }}" .
|
||||
@@ -32,7 +45,7 @@ jobs:
|
||||
run: |
|
||||
docker run -d --name temp-$IMAGE_NAME -p $TEST_PORT:54321 $IMAGE_NAME:latest
|
||||
sleep 10 # Wait for the container to start
|
||||
if curl -f http://localhost:$TEST_PORT/health; then
|
||||
if curl -f --max-time 10 http://localhost:$TEST_PORT/health; then
|
||||
echo "Health check passed"
|
||||
docker stop temp-$IMAGE_NAME
|
||||
docker rm temp-$IMAGE_NAME
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
knet:
|
||||
build: .
|
||||
ports:
|
||||
- "54321:54321"
|
||||
environment:
|
||||
- API_TOKEN=${API_TOKEN:-your-default-token}
|
||||
- API_USERNAME=${API_USERNAME:-api}
|
||||
- BUILD_NUMBER=${BUILD_NUMBER:-dev}
|
||||
networks:
|
||||
- monitoring
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus:latest
|
||||
container_name: knet-prometheus
|
||||
ports:
|
||||
- "9090:9090"
|
||||
volumes:
|
||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- prometheus_data:/prometheus
|
||||
command:
|
||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||
- '--storage.tsdb.path=/prometheus'
|
||||
- '--storage.tsdb.retention.time=30d'
|
||||
- '--storage.tsdb.retention.size=10GB'
|
||||
- '--web.console.libraries=/etc/prometheus/console_libraries'
|
||||
- '--web.console.templates=/etc/prometheus/consoles'
|
||||
- '--web.enable-lifecycle'
|
||||
- '--web.enable-admin-api'
|
||||
networks:
|
||||
- monitoring
|
||||
restart: unless-stopped
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
container_name: knet-grafana
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-admin}
|
||||
volumes:
|
||||
- grafana_data:/var/lib/grafana
|
||||
- ./grafana/provisioning:/etc/grafana/provisioning
|
||||
networks:
|
||||
- monitoring
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- prometheus
|
||||
|
||||
networks:
|
||||
monitoring:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
prometheus_data:
|
||||
grafana_data:
|
||||
28
dockerfile
28
dockerfile
@@ -1,29 +1,15 @@
|
||||
# Step 1: Build the Go binary in a separate stage
|
||||
FROM golang:1.24 AS builder
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the Go module files
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy the rest of the application code
|
||||
COPY ./src ./src
|
||||
|
||||
# Build the Go binary with static linking for Linux
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -v -o /server ./src
|
||||
|
||||
COPY ./views ./views
|
||||
COPY ./build_number ./build_number
|
||||
|
||||
FROM scratch
|
||||
|
||||
# Set the current working directory inside the container
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
COPY --from=builder server ./
|
||||
COPY ./server .
|
||||
COPY ./views ./views
|
||||
COPY ./data ./data
|
||||
COPY ./js ./js
|
||||
COPY ./css ./css
|
||||
COPY ./build_number ./build_number
|
||||
# COPY --from=builder server ./
|
||||
|
||||
# Command to run the executable
|
||||
CMD ["./server"]
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
# Grafana Integration Guide
|
||||
|
||||
This guide shows how to visualize your application metrics with Grafana using **real Prometheus**.
|
||||
|
||||
## Why Prometheus?
|
||||
|
||||
We've replaced custom tracking with actual Prometheus because:
|
||||
- ✅ **Industry standard** - Battle-tested metrics collection
|
||||
- ✅ **Rich ecosystem** - Thousands of existing dashboards
|
||||
- ✅ **Better performance** - Optimized time-series storage
|
||||
- ✅ **Less code** - No custom database or API endpoints
|
||||
- ✅ **Standard metrics** - HTTP requests, response times, etc.
|
||||
|
||||
## Available Metrics
|
||||
|
||||
### Automatic Metrics (from echo-contrib/prometheus)
|
||||
- `http_requests_total` - Total HTTP requests by method/status/endpoint
|
||||
- `http_request_duration_seconds` - Request duration histogram
|
||||
- `http_request_size_bytes` - Request size histogram
|
||||
- `http_response_size_bytes` - Response size histogram
|
||||
|
||||
### Custom Application Metrics
|
||||
- `knet_page_views_total{route="/"}` - Page views by route
|
||||
- `knet_page_views_total{route="/draw"}` - Draw page views
|
||||
- `knet_box_clicks_total{position="1"}` - Box clicks by position
|
||||
- `knet_websocket_connections` - Active WebSocket connections
|
||||
|
||||
## Authentication
|
||||
|
||||
The `/metrics` endpoint uses **Basic Authentication** in production:
|
||||
- **Username:** `api` (or set via `API_USERNAME` environment variable)
|
||||
- **Password:** Your API token (set via `API_TOKEN` environment variable)
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl -u api:your-api-token http://your-server:54321/metrics
|
||||
```
|
||||
|
||||
## Grafana Setup
|
||||
|
||||
### Prometheus Data Source (Recommended)
|
||||
|
||||
1. **Add Prometheus Data Source** in Grafana
|
||||
2. **Set URL** to: `http://your-server:54321`
|
||||
3. **Set Basic Auth**: Username: `api`, Password: `your-api-token`
|
||||
4. **Set Scrape Interval**: 15s (Prometheus scrapes our `/metrics` endpoint)
|
||||
|
||||
That's it! Grafana will automatically discover all available metrics.
|
||||
|
||||
### Sample Dashboard Queries
|
||||
|
||||
**Traffic Overview:**
|
||||
```promql
|
||||
# Total requests per minute
|
||||
rate(http_requests_total[1m]) * 60
|
||||
|
||||
# Page views
|
||||
knet_page_views_total
|
||||
|
||||
# Request duration 95th percentile
|
||||
histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))
|
||||
|
||||
# Error rate
|
||||
rate(http_requests_total{status=~"4.."}[5m]) / rate(http_requests_total[5m]) * 100
|
||||
```
|
||||
|
||||
**Application Metrics:**
|
||||
```promql
|
||||
# Box interactions
|
||||
rate(knet_box_clicks_total[5m])
|
||||
|
||||
# Active WebSocket connections
|
||||
knet_websocket_connections
|
||||
|
||||
# Most popular pages
|
||||
topk(10, rate(knet_page_views_total[1h]))
|
||||
```
|
||||
|
||||
## Dashboard Examples
|
||||
|
||||
### 1. HTTP Overview Panel
|
||||
- **Metric:** `rate(http_requests_total[1m]) * 60`
|
||||
- **Type:** Graph
|
||||
- **Title:** "Requests per Minute"
|
||||
|
||||
### 2. Response Time Panel
|
||||
- **Metric:** `histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))`
|
||||
- **Type:** Single Stat
|
||||
- **Title:** "95th Percentile Response Time"
|
||||
|
||||
### 3. Page Views Panel
|
||||
- **Metric:** `knet_page_views_total`
|
||||
- **Type:** Graph
|
||||
- **Title:** "Page Views by Route"
|
||||
|
||||
### 4. Box Clicks Heatmap
|
||||
- **Metric:** `rate(knet_box_clicks_total[5m])`
|
||||
- **Type:** Graph
|
||||
- **Title:** "Box Clicks per Second"
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Set these for production:
|
||||
```bash
|
||||
export API_TOKEN="your-secure-api-token"
|
||||
export API_USERNAME="api" # Optional, defaults to "api"
|
||||
export BUILD_NUMBER="prod-v1.0" # Enables production mode
|
||||
```
|
||||
|
||||
## Benefits of This Approach
|
||||
|
||||
1. **Standard Prometheus** - Works with all Grafana features
|
||||
2. **Rich Metrics** - HTTP performance + custom business metrics
|
||||
3. **No Database** - Prometheus handles storage and retention
|
||||
4. **Better Performance** - Optimized time-series queries
|
||||
5. **Alerting Ready** - Use Prometheus AlertManager
|
||||
6. **Ecosystem** - Tons of existing dashboards and tools
|
||||
|
||||
## Persistent Storage Setup
|
||||
|
||||
### Option 1: Docker Compose (Recommended)
|
||||
|
||||
For persistent metrics storage, run a Prometheus server alongside your app:
|
||||
|
||||
```bash
|
||||
# 1. Copy your API token to .env
|
||||
cp .env.example .env
|
||||
# Edit .env and set your API_TOKEN
|
||||
|
||||
# 2. Start the full monitoring stack
|
||||
docker-compose up -d
|
||||
|
||||
# 3. Access services
|
||||
# - Your app: http://localhost:54321
|
||||
# - Prometheus: http://localhost:9090
|
||||
# - Grafana: http://localhost:3000 (admin/admin)
|
||||
```
|
||||
|
||||
**What this gives you:**
|
||||
- ✅ **Persistent metrics** - Data survives restarts
|
||||
- ✅ **30-day retention** - Configurable in docker-compose.yml
|
||||
- ✅ **Grafana pre-configured** - Points to Prometheus automatically
|
||||
- ✅ **Production ready** - Proper volumes and networking
|
||||
|
||||
### Option 2: Standalone Prometheus
|
||||
|
||||
If you prefer to run Prometheus separately:
|
||||
|
||||
```bash
|
||||
# 1. Update prometheus.yml with your API token
|
||||
# 2. Start Prometheus
|
||||
prometheus --config.file=prometheus.yml --storage.tsdb.path=./prometheus_data
|
||||
|
||||
# 3. Configure Grafana to point to http://localhost:9090
|
||||
```
|
||||
|
||||
### Grafana Data Source Configuration
|
||||
|
||||
With persistent Prometheus:
|
||||
1. **Add Prometheus Data Source** in Grafana
|
||||
2. **Set URL** to: `http://prometheus:9090` (Docker) or `http://localhost:9090` (standalone)
|
||||
3. **No authentication needed** - Prometheus handles the API token
|
||||
4. **Query retention**: Up to 30 days of historical data
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Start your server:** `./tmp/main`
|
||||
2. **Test metrics:** `curl -u api:your-token http://localhost:54321/metrics`
|
||||
3. **Add Prometheus data source** in Grafana: `http://localhost:54321`
|
||||
4. **Create dashboard** with queries like `http_requests_total`
|
||||
5. **Enjoy real-time metrics!** 📊
|
||||
|
||||
Your analytics are now powered by industry-standard Prometheus! <20>
|
||||
@@ -1,164 +0,0 @@
|
||||
# Tracking API Documentation
|
||||
|
||||
The tracking service provides analytics about endpoint usage with time-based filtering.
|
||||
|
||||
## Authentication
|
||||
|
||||
In production, all tracking endpoints require Bearer token authentication:
|
||||
```
|
||||
Authorization: Bearer <your-api-token>
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
### GET /api/tracking/stats
|
||||
|
||||
Get endpoint statistics with optional time period filtering.
|
||||
|
||||
**Query Parameters:**
|
||||
- `period` (optional): `day`, `week`, `month`, `year`, `all` (default: `all`)
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
# Get all-time stats
|
||||
curl -H "Authorization: Bearer $API_TOKEN" /api/tracking/stats
|
||||
|
||||
# Get last week's stats
|
||||
curl -H "Authorization: Bearer $API_TOKEN" /api/tracking/stats?period=week
|
||||
|
||||
# Get last 30 days
|
||||
curl -H "Authorization: Bearer $API_TOKEN" /api/tracking/stats?period=month
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"stats": [
|
||||
{
|
||||
"endpoint": "/",
|
||||
"method": "GET",
|
||||
"hit_count": 245,
|
||||
"unique_users": 89,
|
||||
"last_access": "2025-08-13T10:30:00Z",
|
||||
"first_access": "2025-08-06T14:22:00Z"
|
||||
}
|
||||
],
|
||||
"period": "week",
|
||||
"total_endpoints": 8
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/tracking/summary
|
||||
|
||||
Get overall tracking summary for a time period.
|
||||
|
||||
**Query Parameters:**
|
||||
- `period` (optional): `day`, `week`, `month`, `year`, `all` (default: `all`)
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
# Get today's summary
|
||||
curl -H "Authorization: Bearer $API_TOKEN" /api/tracking/summary?period=day
|
||||
|
||||
# Get yearly summary
|
||||
curl -H "Authorization: Bearer $API_TOKEN" /api/tracking/summary?period=year
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"period": "day",
|
||||
"total_hits": 1247,
|
||||
"total_endpoints": 12,
|
||||
"total_unique_users": 234,
|
||||
"oldest_visit": "2025-08-13T00:15:00Z",
|
||||
"newest_visit": "2025-08-13T23:45:00Z",
|
||||
"tracking_active": true
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/tracking/trends
|
||||
|
||||
Get visit trends over time with configurable granularity.
|
||||
|
||||
**Query Parameters:**
|
||||
- `granularity` (optional): `hour`, `day`, `week`, `month` (default: `day`)
|
||||
- `period` (optional): `24h`, `7d`, `30d`, `1y` (default: `7d`)
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
# Hourly trends for last 24 hours
|
||||
curl -H "Authorization: Bearer $API_TOKEN" /api/tracking/trends?granularity=hour&period=24h
|
||||
|
||||
# Daily trends for last 30 days
|
||||
curl -H "Authorization: Bearer $API_TOKEN" /api/tracking/trends?granularity=day&period=30d
|
||||
|
||||
# Weekly trends for last year
|
||||
curl -H "Authorization: Bearer $API_TOKEN" /api/tracking/trends?granularity=week&period=1y
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"trends": [
|
||||
{
|
||||
"time_bucket": "2025-08-13",
|
||||
"hits": 567,
|
||||
"unique_users": 123,
|
||||
"unique_endpoints": 8
|
||||
},
|
||||
{
|
||||
"time_bucket": "2025-08-12",
|
||||
"hits": 445,
|
||||
"unique_users": 98,
|
||||
"unique_endpoints": 7
|
||||
}
|
||||
],
|
||||
"granularity": "day",
|
||||
"period": "7d"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/tracking/visits
|
||||
|
||||
Get recent individual visits with optional filtering.
|
||||
|
||||
**Query Parameters:**
|
||||
- `limit` (optional): Number of visits to return (default: `50`)
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $API_TOKEN" /api/tracking/visits?limit=20
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"visits": [
|
||||
{
|
||||
"user_hash": "a1b2c3d4e5f6...",
|
||||
"endpoint": "/boxes",
|
||||
"method": "GET",
|
||||
"ip_address": "192.168.1.100",
|
||||
"user_agent": "Mozilla/5.0...",
|
||||
"referer": "https://google.com",
|
||||
"timestamp": "2025-08-13T15:30:00Z"
|
||||
}
|
||||
],
|
||||
"count": 20
|
||||
}
|
||||
```
|
||||
|
||||
## Benefits of This Approach
|
||||
|
||||
1. **No Duplication**: Single source of truth in `user_visits` table
|
||||
2. **Flexible Querying**: Any time period can be queried on-demand
|
||||
3. **Rich Analytics**: Detailed trends and user behavior insights
|
||||
4. **Efficient Storage**: Only raw visits stored, stats calculated in real-time
|
||||
5. **Historical Analysis**: Can analyze any historical period without pre-aggregation
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- Indexes are created on `timestamp`, `endpoint`, and `user_hash` for fast queries
|
||||
- For very high traffic, consider adding materialized views for common time periods
|
||||
- The SQLite database handles thousands of requests efficiently with proper indexing
|
||||
35
go.mod
35
go.mod
@@ -1,39 +1,32 @@
|
||||
module knet.sk
|
||||
|
||||
go 1.24.0
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/labstack/echo-contrib v0.17.4
|
||||
github.com/labstack/echo/v4 v4.13.4
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/labstack/echo/v4 v4.15.0
|
||||
github.com/labstack/gommon v0.4.2
|
||||
github.com/prometheus/client_golang v1.23.0
|
||||
golang.org/x/net v0.42.0
|
||||
modernc.org/sqlite v1.38.2
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
golang.org/x/net v0.49.0
|
||||
modernc.org/sqlite v1.44.3
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mileusna/useragent v1.3.5 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.65.0 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
golang.org/x/crypto v0.40.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
golang.org/x/time v0.12.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
modernc.org/libc v1.66.3 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
modernc.org/libc v1.67.7 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
141
go.sum
141
go.sum
@@ -1,92 +1,105 @@
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/labstack/echo-contrib v0.17.4 h1:g5mfsrJfJTKv+F5uNKCyrjLK7js+ZW6HTjg4FnDxxgk=
|
||||
github.com/labstack/echo-contrib v0.17.4/go.mod h1:9O7ZPAHUeMGTOAfg80YqQduHzt0CzLak36PZRldYrZ0=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
|
||||
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
|
||||
github.com/labstack/echo/v4 v4.14.0 h1:+tiMrDLxwv6u0oKtD03mv+V1vXXB3wCqPHJqPuIe+7M=
|
||||
github.com/labstack/echo/v4 v4.14.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
|
||||
github.com/labstack/echo/v4 v4.15.0 h1:hoRTKWcnR5STXZFe9BmYun9AMTNeSbjHi2vtDuADJ24=
|
||||
github.com/labstack/echo/v4 v4.15.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
|
||||
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
|
||||
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
|
||||
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY=
|
||||
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
|
||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
||||
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
|
||||
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||
modernc.org/libc v1.67.1 h1:bFaqOaa5/zbWYJo8aW0tXPX21hXsngG2M7mckCnFSVk=
|
||||
modernc.org/libc v1.67.1/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
|
||||
modernc.org/libc v1.67.7 h1:H+gYQw2PyidyxwxQsGTwQw6+6H+xUk+plvOKW7+d3TI=
|
||||
modernc.org/libc v1.67.7/go.mod h1:UjCSJFl2sYbJbReVQeVpq/MgzlbmDM4cRHIYFelnaDk=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
@@ -95,8 +108,12 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
||||
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||
modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
|
||||
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||
modernc.org/sqlite v1.41.0 h1:bJXddp4ZpsqMsNN1vS0jWo4IJTZzb8nWpcgvyCFG9Ck=
|
||||
modernc.org/sqlite v1.41.0/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
|
||||
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
DROP INDEX IF EXISTS idx_user_visits_path;
|
||||
DROP INDEX IF EXISTS idx_user_visits_fingerprint;
|
||||
DROP INDEX IF EXISTS idx_user_visits_timestamp;
|
||||
DROP TABLE IF EXISTS user_visits;
|
||||
15
migrations/tracking/000001_create_user_visits_table.up.sql
Normal file
15
migrations/tracking/000001_create_user_visits_table.up.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE IF NOT EXISTS user_visits (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
fingerprint TEXT,
|
||||
user_agent TEXT,
|
||||
referer TEXT,
|
||||
accept_language TEXT,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_visits_timestamp ON user_visits(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_visits_fingerprint ON user_visits(fingerprint);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_visits_path ON user_visits(path);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- This migration is irreversible as we're deleting historical data
|
||||
-- If needed, you would need to restore from a backup
|
||||
|
||||
-- No-op down migration since we can't restore deleted tracking events
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,29 @@
|
||||
-- Remove tracking events that match current ignored patterns
|
||||
|
||||
-- Remove visits to ignored paths
|
||||
DELETE FROM user_visits WHERE
|
||||
path = '/tracking' OR
|
||||
path = '/metrics' OR
|
||||
path LIKE '/css/%' OR
|
||||
path LIKE '/js/%' OR
|
||||
path = '/boxes/ws' OR
|
||||
path LIKE '%favicon%';
|
||||
|
||||
-- Remove visits from ignored user agents
|
||||
DELETE FROM user_visits WHERE
|
||||
user_agent LIKE '%Prometheus%' OR
|
||||
user_agent LIKE '%UptimeRobot%' OR
|
||||
user_agent LIKE 'NetworkingExtension%';
|
||||
|
||||
-- Optional: Clean up any other common bot/monitoring patterns that might exist
|
||||
DELETE FROM user_visits WHERE
|
||||
user_agent LIKE '%bot%' OR
|
||||
user_agent LIKE '%Bot%' OR
|
||||
user_agent LIKE '%crawler%' OR
|
||||
user_agent LIKE '%Crawler%' OR
|
||||
user_agent LIKE '%spider%' OR
|
||||
user_agent LIKE '%Spider%' OR
|
||||
user_agent LIKE '%monitor%' OR
|
||||
user_agent LIKE '%Monitor%' OR
|
||||
user_agent LIKE '%health%' OR
|
||||
user_agent LIKE '%Health%';
|
||||
@@ -0,0 +1,5 @@
|
||||
-- This migration is irreversible as we're deleting historical data
|
||||
-- If needed, you would need to restore from a backup
|
||||
|
||||
-- No-op down migration since we can't restore deleted tracking events
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Remove tracking events that match current ignored patterns
|
||||
|
||||
-- Remove visits to ignored paths
|
||||
DELETE FROM user_visits WHERE
|
||||
'path' = '/tracking' OR
|
||||
'path' = '/metrics' OR
|
||||
'path' LIKE '/css/%' OR
|
||||
'path' LIKE '/js/%' OR
|
||||
'path' = '/boxes/ws' OR
|
||||
'path' LIKE '%favicon%';
|
||||
@@ -19,7 +19,7 @@
|
||||
"dev": "tsc --project tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.3"
|
||||
"typescript": "^5.9.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"mitt": "^3.0.1"
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -13,16 +13,16 @@ importers:
|
||||
version: 3.0.1
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: ^5.7.3
|
||||
version: 5.7.3
|
||||
specifier: ^5.9.2
|
||||
version: 5.9.2
|
||||
|
||||
packages:
|
||||
|
||||
mitt@3.0.1:
|
||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||
|
||||
typescript@5.7.3:
|
||||
resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==}
|
||||
typescript@5.9.2:
|
||||
resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
@@ -30,4 +30,4 @@ snapshots:
|
||||
|
||||
mitt@3.0.1: {}
|
||||
|
||||
typescript@5.7.3: {}
|
||||
typescript@5.9.2: {}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
# Prometheus configuration for knet application
|
||||
global:
|
||||
scrape_interval: 15s # How often to scrape targets
|
||||
evaluation_interval: 15s # How often to evaluate rules
|
||||
|
||||
# Attach these labels to any time series or alerts when communicating with
|
||||
# external systems (federation, remote storage, Alertmanager).
|
||||
external_labels:
|
||||
monitor: 'knet-monitor'
|
||||
|
||||
# Alertmanager configuration (optional)
|
||||
alerting:
|
||||
alertmanagers:
|
||||
- static_configs:
|
||||
- targets:
|
||||
# - alertmanager:9093
|
||||
|
||||
# Load rules once and periodically evaluate them according to the global 'evaluation_interval'.
|
||||
rule_files:
|
||||
# - "first_rules.yml"
|
||||
# - "second_rules.yml"
|
||||
|
||||
# A scrape configuration containing exactly one endpoint to scrape:
|
||||
scrape_configs:
|
||||
# The job name is added as a label `job=<job_name>` to any timeseries scraped from this config.
|
||||
- job_name: 'knet-app'
|
||||
|
||||
# metrics_path defaults to '/metrics'
|
||||
# scheme defaults to 'http'.
|
||||
|
||||
# How often to scrape this target
|
||||
scrape_interval: 15s
|
||||
|
||||
# Timeout for scraping
|
||||
scrape_timeout: 10s
|
||||
|
||||
# Use HTTPS to connect to the target
|
||||
scheme: https
|
||||
|
||||
# Basic authentication for protected endpoints
|
||||
basic_auth:
|
||||
username: 'api'
|
||||
password: 'your-default-token' # This should match your API_TOKEN env var
|
||||
|
||||
static_configs:
|
||||
- targets: ['knet.sk'] # Your knet application
|
||||
labels:
|
||||
instance: 'knet-prod'
|
||||
environment: 'production'
|
||||
|
||||
# Only scrape metrics endpoint
|
||||
metrics_path: /metrics
|
||||
|
||||
# Optional: Add custom headers
|
||||
# headers:
|
||||
# X-Custom-Header: value
|
||||
|
||||
# Optional: Monitor Prometheus itself
|
||||
- job_name: 'prometheus'
|
||||
static_configs:
|
||||
- targets: ['localhost:9090']
|
||||
|
||||
# Storage configuration
|
||||
# Prometheus stores data in ./data by default
|
||||
# You can customize with command line flags:
|
||||
# --storage.tsdb.path=/prometheus
|
||||
# --storage.tsdb.retention.time=15d
|
||||
# --storage.tsdb.retention.size=10GB
|
||||
64
src/main.go
64
src/main.go
@@ -4,16 +4,12 @@ import (
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo-contrib/prometheus"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
"github.com/labstack/gommon/log"
|
||||
promclient "github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"knet.sk/src/boxes"
|
||||
"knet.sk/src/draw"
|
||||
"knet.sk/src/tracking"
|
||||
@@ -50,46 +46,16 @@ func newPage(boxes []boxes.Box) Page {
|
||||
|
||||
var (
|
||||
e = echo.New()
|
||||
|
||||
// Custom Prometheus metrics
|
||||
boxClicks = promclient.NewCounterVec(
|
||||
promclient.CounterOpts{
|
||||
Name: "knet_box_clicks_total",
|
||||
Help: "Total number of box clicks by position",
|
||||
},
|
||||
[]string{"position"},
|
||||
)
|
||||
|
||||
activeConnections = promclient.NewGauge(
|
||||
promclient.GaugeOpts{
|
||||
Name: "knet_websocket_connections",
|
||||
Help: "Current number of active WebSocket connections",
|
||||
},
|
||||
)
|
||||
|
||||
pageViews = promclient.NewCounterVec(
|
||||
promclient.CounterOpts{
|
||||
Name: "knet_page_views_total",
|
||||
Help: "Total page views by route",
|
||||
},
|
||||
[]string{"route"},
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Register custom metrics
|
||||
promclient.MustRegister(boxClicks)
|
||||
promclient.MustRegister(activeConnections)
|
||||
promclient.MustRegister(pageViews)
|
||||
|
||||
}
|
||||
|
||||
func main() {
|
||||
e.Renderer = NewTemplates()
|
||||
|
||||
// Setup Prometheus metrics
|
||||
p := prometheus.NewPrometheus("knet", nil)
|
||||
p.Use(e)
|
||||
|
||||
e.Logger.SetLevel(log.DEBUG)
|
||||
e.Use(middleware.Logger())
|
||||
if util.IsProd() {
|
||||
@@ -101,27 +67,13 @@ func main() {
|
||||
}))
|
||||
}
|
||||
|
||||
// MY supper tracking
|
||||
e.Use(tracking.EchoWithConfig(tracking.TrackingConfig{
|
||||
IgnorePaths: []string{"/tracking", "/metrics", "/css/*", "/js/*", "/boxes/ws"},
|
||||
IgnoreUserAgents: []string{"*Prometheus*", "*UptimeRobot*"},
|
||||
IgnorePaths: []string{"/tracking", "/metrics", "/css/*", "/js/*", "/boxes/ws", "*favicon*"},
|
||||
IgnoreUserAgents: []string{},
|
||||
}))
|
||||
trackingGroup := e.Group("/tracking")
|
||||
trackingGroup.GET("", tracking.BasicTrackingHandler)
|
||||
|
||||
// Prometheus metrics endpoint (standard /metrics)
|
||||
metricsGroup := e.Group("/metrics")
|
||||
if util.IsProd() {
|
||||
metricsGroup.Use(middleware.BasicAuth(func(username, password string, c echo.Context) (bool, error) {
|
||||
expectedToken := os.Getenv("API_TOKEN")
|
||||
if expectedToken == "" {
|
||||
return false, nil
|
||||
}
|
||||
return username == "api" && password == expectedToken, nil
|
||||
}))
|
||||
}
|
||||
metricsGroup.GET("", echo.WrapHandler(promhttp.Handler()))
|
||||
|
||||
e.GET("/health", func(c echo.Context) error {
|
||||
return c.Render(200, "health", Page{
|
||||
BuildNumber: util.GetBuildNumber(),
|
||||
@@ -145,15 +97,21 @@ func main() {
|
||||
|
||||
e.GET("/", func(c echo.Context) error {
|
||||
// Track page view
|
||||
pageViews.WithLabelValues("/").Inc()
|
||||
return c.Render(200, "index", newPage(boxes.GetBoxes()))
|
||||
})
|
||||
|
||||
e.GET("/timer", func(c echo.Context) error {
|
||||
// Track page view
|
||||
return c.Render(200, "timer", Page{
|
||||
BuildNumber: util.GetBuildNumber(),
|
||||
Integrity: util.CalculateAssetIntegrities(),
|
||||
})
|
||||
})
|
||||
|
||||
e.GET("/boxes/ws", boxes.HandleBoxesWs)
|
||||
|
||||
e.GET("/draw", func(c echo.Context) error {
|
||||
// Track page view
|
||||
pageViews.WithLabelValues("/draw").Inc()
|
||||
return draw.Page(c)
|
||||
})
|
||||
e.GET("/draw/ws", draw.InitWs)
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
// GetActiveConnections returns the current number of active WebSocket connections
|
||||
// This function will be called by Prometheus to get the current gauge value
|
||||
func GetActiveConnections() prometheus.GaugeFunc {
|
||||
return prometheus.NewGaugeFunc(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "knet_websocket_connections_active",
|
||||
Help: "Current number of active WebSocket connections",
|
||||
},
|
||||
func() float64 {
|
||||
// This will be implemented to call boxes.GetConnectionCount()
|
||||
return 0 // Placeholder for now
|
||||
},
|
||||
)
|
||||
}
|
||||
52
src/migrations/manager.go
Normal file
52
src/migrations/manager.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/sqlite"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
)
|
||||
|
||||
type MigrationManager struct {
|
||||
databases map[string]*sql.DB
|
||||
}
|
||||
|
||||
func NewMigrationManager() *MigrationManager {
|
||||
return &MigrationManager{
|
||||
databases: make(map[string]*sql.DB),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterDatabase registers a database connection for migrations
|
||||
func (mm *MigrationManager) RegisterDatabase(name string, db *sql.DB) {
|
||||
mm.databases[name] = db
|
||||
}
|
||||
|
||||
// RunMigrations runs migrations for a specific database
|
||||
func (mm *MigrationManager) RunMigrations(dbName string) error {
|
||||
db, exists := mm.databases[dbName]
|
||||
if !exists {
|
||||
return fmt.Errorf("database %s not registered", dbName)
|
||||
}
|
||||
|
||||
driver, err := sqlite.WithInstance(db, &sqlite.Config{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create database driver: %w", err)
|
||||
}
|
||||
|
||||
migrationPath := fmt.Sprintf("file://migrations/%s", dbName)
|
||||
m, err := migrate.NewWithDatabaseInstance(migrationPath, "sqlite", driver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create migration instance: %w", err)
|
||||
}
|
||||
|
||||
// Run migrations
|
||||
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
|
||||
return fmt.Errorf("failed to run migrations: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Migrations completed successfully for database: %s\n", dbName)
|
||||
return nil
|
||||
}
|
||||
@@ -21,6 +21,7 @@ type VisitDisplay struct {
|
||||
IPAddress string
|
||||
Path string
|
||||
UserAgent string
|
||||
RawUserAgent string
|
||||
Referrer string
|
||||
}
|
||||
|
||||
@@ -77,7 +78,7 @@ func BasicTrackingHandler(c echo.Context) error {
|
||||
UniqueVisitors: ts.GetUniqueVisitors(),
|
||||
TodayVisits: ts.GetTodayVisits(),
|
||||
ActiveSessions: ts.GetActiveSessions(),
|
||||
RecentVisits: ts.GetRecentVisits(20),
|
||||
RecentVisits: ts.GetRecentVisits(100),
|
||||
TopPages: ts.GetTopPages(5),
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,12 @@ package tracking
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"knet.sk/src/migrations"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
@@ -43,10 +45,19 @@ func StartTrackingService() *TrackingService {
|
||||
db.SetMaxIdleConns(1)
|
||||
|
||||
trackingService = &TrackingService{db: db}
|
||||
err = trackingService.initDB()
|
||||
if err != nil {
|
||||
|
||||
// Run migrations instead of manual table creation
|
||||
migrationManager := migrations.NewMigrationManager()
|
||||
migrationManager.RegisterDatabase("tracking", db)
|
||||
if err := migrationManager.RunMigrations("tracking"); err != nil {
|
||||
println("Warning: Migration failed:", err.Error())
|
||||
// Fallback to manual table creation for backward compatibility
|
||||
if err := trackingService.initDB(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
} else {
|
||||
log.Println("Tracking database migrations applied successfully.")
|
||||
}
|
||||
}
|
||||
|
||||
return trackingService
|
||||
@@ -190,6 +201,8 @@ func (ts *TrackingService) GetRecentVisits(limit int) []VisitDisplay {
|
||||
visit.Timestamp = rawTimestamp
|
||||
}
|
||||
|
||||
// Store raw user agent before parsing
|
||||
visit.RawUserAgent = visit.UserAgent
|
||||
visit.UserAgent = parseUserAgent(visit.UserAgent)
|
||||
visits = append(visits, visit)
|
||||
}
|
||||
|
||||
207
views/timer.html
Normal file
207
views/timer.html
Normal file
@@ -0,0 +1,207 @@
|
||||
{{block "timer" .}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>November Countdown</title>
|
||||
<link rel="stylesheet" href="/css/main.css?v={{.BuildNumber}}" integrity="{{index .Integrity.CSS "main.css" }}" crossorigin="anonymous" />
|
||||
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="/images/favicon.ico" />
|
||||
<style>
|
||||
.countdown-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.countdown-title {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
color: var(--color);
|
||||
}
|
||||
|
||||
.countdown-timer {
|
||||
font-family: var(--font-family);
|
||||
font-size: 4rem;
|
||||
font-weight: bold;
|
||||
color: var(--secondary);
|
||||
margin-bottom: 1rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-shadow: 0 0 10px rgba(197, 134, 192, 0.3);
|
||||
}
|
||||
|
||||
.countdown-labels {
|
||||
display: flex;
|
||||
gap: 3rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.countdown-unit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.countdown-number {
|
||||
font-size: 4rem;
|
||||
font-weight: bold;
|
||||
color: var(--secondary);
|
||||
line-height: 1;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.countdown-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color);
|
||||
opacity: 0.8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.countdown-timer {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.countdown-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.countdown-number {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.countdown-labels {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.countdown-timer {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.countdown-title {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.countdown-number {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.countdown-labels {
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav class="logo">
|
||||
<a href="/">
|
||||
<svg width="64" height="64" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Background circle for better visibility -->
|
||||
<circle cx="16" cy="16" r="15" fill="#1e1e1e" stroke="var(--color)" stroke-width="2"/>
|
||||
<!-- Vertical line of K -->
|
||||
<line x1="10" y1="8" x2="10" y2="24" stroke="var(--color)" stroke-width="2" stroke-linecap="round"/>
|
||||
<!-- Upper diagonal of K -->
|
||||
<line x1="10" y1="16" x2="22" y2="8" stroke="var(--color)" stroke-width="2" stroke-linecap="round"/>
|
||||
<!-- Lower diagonal of K -->
|
||||
<line x1="10" y1="16" x2="22" y2="24" stroke="var(--color)" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="countdown-container">
|
||||
<h1 class="countdown-title">Countdown to December 1st</h1>
|
||||
<div class="countdown-display">
|
||||
<div class="countdown-labels">
|
||||
<div class="countdown-unit">
|
||||
<div class="countdown-number" id="days">00</div>
|
||||
<div class="countdown-label">Days</div>
|
||||
</div>
|
||||
<div class="countdown-unit">
|
||||
<div class="countdown-number" id="hours">00</div>
|
||||
<div class="countdown-label">Hours</div>
|
||||
</div>
|
||||
<div class="countdown-unit">
|
||||
<div class="countdown-number" id="minutes">00</div>
|
||||
<div class="countdown-label">Minutes</div>
|
||||
</div>
|
||||
<div class="countdown-unit">
|
||||
<div class="countdown-number" id="seconds">00</div>
|
||||
<div class="countdown-label">Seconds</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<nav>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://www.goodreads.com/jurajk">Goodreads</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://open.spotify.com/playlist/1X6PkYyptMu0kOFhVGTTkS?si=f55894a8c1e74f42">Current vibe Spotify Playlist</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/JurajKubrican">Github</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.linkedin.com/in/juraj-kubri%C4%8Dan-614b3274/">LinkedIn</a >
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
function updateCountdown() {
|
||||
const now = new Date().getTime();
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
// Target is December 1st of current year at 00:00:00
|
||||
// If we're already past December 1st, target next year's December 1st
|
||||
let targetDate = new Date(currentYear, 11, 1, 0, 0, 0, 0); // Month 11 = December
|
||||
|
||||
if (now > targetDate.getTime()) {
|
||||
targetDate = new Date(currentYear + 1, 11, 1, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
const distance = targetDate.getTime() - now;
|
||||
|
||||
if (distance < 0) {
|
||||
document.getElementById('days').textContent = '00';
|
||||
document.getElementById('hours').textContent = '00';
|
||||
document.getElementById('minutes').textContent = '00';
|
||||
document.getElementById('seconds').textContent = '00';
|
||||
return;
|
||||
}
|
||||
|
||||
const days = Math.floor(distance / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const seconds = Math.floor((distance % (1000 * 60)) / 1000);
|
||||
|
||||
document.getElementById('days').textContent = days.toString().padStart(2, '0');
|
||||
document.getElementById('hours').textContent = hours.toString().padStart(2, '0');
|
||||
document.getElementById('minutes').textContent = minutes.toString().padStart(2, '0');
|
||||
document.getElementById('seconds').textContent = seconds.toString().padStart(2, '0');
|
||||
}
|
||||
|
||||
// Update countdown immediately and then every second
|
||||
updateCountdown();
|
||||
setInterval(updateCountdown, 1000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -114,7 +114,7 @@
|
||||
<td>{{.Timestamp}}</td>
|
||||
<td>{{.IPAddress}}</td>
|
||||
<td>{{.Path}}</td>
|
||||
<td>{{.UserAgent}}</td>
|
||||
<td title="{{.RawUserAgent}}">{{.UserAgent}}</td>
|
||||
<td>{{.Referrer}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
|
||||
Reference in New Issue
Block a user