commit 44cd9ab0016d907f19d4f3cf10e77a5ae23e0201 Author: Kevin Date: Mon May 4 13:50:15 2026 +0200 Initial commit: LogMaster with Viewer Collector Monitor modes diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0563df0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +dist +dist-server +.git +.env +*.md +src/main +src/preload +src/renderer diff --git a/.env.collector b/.env.collector new file mode 100644 index 0000000..93e35f2 --- /dev/null +++ b/.env.collector @@ -0,0 +1,24 @@ +# Log Viewer - COLLECTOR MODE +LOG_MODE=collector +PORT=8081 +HOST=0.0.0.0 + +# UDP receiver +COLLECTOR_UDP_ENABLED=true +COLLECTOR_UDP_PORT=5140 + +# Storage +COLLECTOR_STORAGE_DIR=./collector-data +COLLECTOR_MAX_LINES=100000 +COLLECTOR_MAX_STREAMS=50 +COLLECTOR_PERSIST=true + +# Rotation (what triggers first) +COLLECTOR_ROTATION_MAX_SIZE=5242880 +COLLECTOR_ROTATION_MAX_AGE=600000 +COLLECTOR_ROTATION_COMPRESSION=gzip +COLLECTOR_ROTATION_EXTENSION=.log + +# Forward targets (JSON array) +# COLLECTOR_FORWARD_TARGETS=[{"type":"http","url":"http://other-server:8080/collect/forwarded","format":"json"},{"type":"file","path":"./forwarded.log"},{"type":"console"}] +COLLECTOR_FORWARD_TARGETS=[{"type":"console"}] diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..33fd635 --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# Log Viewer - Environment Configuration +# ======================================== + +# Log sources - comma-separated list of file paths or glob patterns +# Supports: .log, .txt, plain files (no extension), .gz, .bz2, .xz, .zst +LOG_SOURCES=/var/log/syslog,/var/log/auth.log,/tmp/myapp/*.log + +# Optional: Refresh interval in milliseconds (default: 2000) +LOG_REFRESH_INTERVAL=2000 + +# Optional: Maximum lines to load per file (default: 50000) +LOG_MAX_LINES=50000 + +# Optional: Default theme (dark or light, default: dark) +LOG_THEME=dark + +# Optional: Default log level filter (ALL, DEBUG, INFO, WARN, ERROR, FATAL) +LOG_DEFAULT_LEVEL=ALL + +# Optional: Enable tail mode by default (true/false, default: true) +LOG_TAIL_MODE=true + +# Optional: Custom timestamp format regex for parsing +# LOG_TIMESTAMP_REGEX=\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2} diff --git a/.env.monitor b/.env.monitor new file mode 100644 index 0000000..f73e129 --- /dev/null +++ b/.env.monitor @@ -0,0 +1,12 @@ +# Log Viewer - MONITOR MODE +LOG_MODE=monitor +PORT=8082 +HOST=0.0.0.0 + +# Monitor settings +MONITOR_ENABLED=true +MONITOR_SCAN_INTERVAL=60000 +MONITOR_STORAGE_DIR=./monitor-data + +# Rules can be configured via the GUI or as JSON: +# MONITOR_RULES=[{"id":"rule-1","name":"DB Admin Login","enabled":true,"sources":["sample-logs/app.log"],"patterns":["admin.*authenticated","GRANT"],"patternLogic":"any","timeRange":{"type":"week"},"notifications":[{"type":"webhook","url":"http://localhost:9999/hook"}],"maxLinesInMessage":20,"cooldownMs":300000,"matchCount":0,"lastTriggered":null}] diff --git a/.env.viewer b/.env.viewer new file mode 100644 index 0000000..3af013a --- /dev/null +++ b/.env.viewer @@ -0,0 +1,15 @@ +# Log Viewer - VIEWER MODE +LOG_MODE=viewer +PORT=9090 +HOST=0.0.0.0 + +# Connect to the collector +REMOTE_COLLECTOR_URL=http://localhost:8081 + +# Local file sources (optional, in addition to remote streams) +LOG_SOURCES=sample-logs/app.log,sample-logs/nginx-access.log,sample-logs/syslog,sample-logs/archived.log.gz + +# UI settings +LOG_THEME=dark +LOG_REFRESH_INTERVAL=2000 +LOG_TAIL_MODE=true diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..602d3eb --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,64 @@ +name: LogMaster CI/CD + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build (TypeScript compile) + run: npx tsc -p tsconfig.server.json + + - name: Generate sample logs + run: node sample-logs/generate-logs.js + + - name: Start services + run: | + LOG_MODE=collector PORT=8081 COLLECTOR_UDP_ENABLED=false node dist-server/server.js & + LOG_MODE=viewer PORT=9090 REMOTE_COLLECTOR_URL=http://localhost:8081 LOG_SOURCES=sample-logs/app.log,sample-logs/nginx-access.log,sample-logs/syslog,sample-logs/archived.log.gz node dist-server/server.js & + LOG_MODE=monitor PORT=8082 MONITOR_ENABLED=true node dist-server/server.js & + sleep 3 + + - name: Run integration tests + run: node run-all-tests.js + + - name: Stop services + if: always() + run: pkill -f "node dist-server" || true + + docker: + runs-on: ubuntu-latest + needs: build-and-test + if: github.ref == 'refs/heads/main' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build Docker image + run: docker build -t logmaster:latest -t logmaster:${{ github.sha }} . + + - name: Login to Gitea Registry + run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.kgessner.de -u "${{ secrets.REGISTRY_USER }}" --password-stdin + + - name: Tag and push + run: | + docker tag logmaster:latest git.kgessner.de/keviin/logmaster:latest + docker tag logmaster:${{ github.sha }} git.kgessner.de/keviin/logmaster:${{ github.sha }} + docker push git.kgessner.de/keviin/logmaster:latest + docker push git.kgessner.de/keviin/logmaster:${{ github.sha }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa0926a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.env +*.log diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7a73a41 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/DOCS.md b/DOCS.md new file mode 100644 index 0000000..3264282 --- /dev/null +++ b/DOCS.md @@ -0,0 +1,690 @@ +# LogMaster — Complete Documentation + +A modular log management platform with four operating modes, a web UI for each, and full API control. Zero external dependencies beyond Node.js. + +--- + +## Table of Contents + +- [Architecture](#architecture) +- [Quick Start](#quick-start) +- [Operating Modes](#operating-modes) +- [Viewer](#viewer) +- [Collector](#collector) +- [Monitor](#monitor) +- [S3 Integration](#s3-integration) +- [Configuration](#configuration) +- [API Reference](#api-reference) +- [GUI Walkthroughs](#gui-walkthroughs) +- [Example Scenarios](#example-scenarios) +- [Docker & CI/CD](#docker--cicd) +- [Keyboard Shortcuts](#keyboard-shortcuts) + +--- + +## Architecture + +``` + ┌──────────────────────────┐ + │ LOG SOURCES │ + ├──────┬──────┬──────┬──────┤ + │Files │ S3 │ UDP │ HTTP │ + │.log │Bucket│:5140 │POST │ + │.gz │ │ │/collect + └──┬───┴──┬───┴──┬───┴──┬───┘ + │ │ │ │ + ▼ ▼ ▼ ▼ +┌──────────────────────────────────────────────────────────────┐ +│ LOGMASTER SERVER │ +│ │ +│ ┌──────────┐ ┌───────────┐ ┌──────────┐ ┌─────────┐ │ +│ │ VIEWER │ │ COLLECTOR │ │ MONITOR │ │ ALL │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ Read │ │ Receive │ │ Scan │ │ Every │ │ +│ │ Filter │ │ Store │ │ Pattern │ │ feature │ │ +│ │ Search │ │ Rotate │ │ Alert │ │ active │ │ +│ │ Export │ │ Forward │ │ Notify │ │ │ │ +│ └──────────┘ └───────────┘ └──────────┘ └─────────┘ │ +│ │ +│ Forward Targets: HTTP │ File │ UDP │ Stream │ Console │ +│ Notifications: Telegram │ Webhook │ Teams │ Email │ +└──────────────────────────────────────────────────────────────┘ +``` + +--- + +## Quick Start + +```bash +npm install +npm run build # Compile TypeScript +node dist-server/server.js # Start in "both" mode (viewer+collector) on :8080 +``` + +Open http://localhost:8080. + +### Start specific modes + +```bash +# Collector on port 8081 +LOG_MODE=collector PORT=8081 node dist-server/server.js + +# Viewer on port 9090, reading from collector +LOG_MODE=viewer PORT=9090 REMOTE_COLLECTOR_URL=http://localhost:8081 LOG_SOURCES=./logs/*.log node dist-server/server.js + +# Monitor on port 8082 +LOG_MODE=monitor PORT=8082 node dist-server/server.js +``` + +--- + +## Operating Modes + +| Mode | `LOG_MODE=` | What it does | Web UI | +|------|-------------|-------------|--------| +| Viewer | `viewer` | Read & display logs | Log viewer with filter, search, export | +| Collector | `collector` | Receive, store, rotate, forward logs | Stream dashboard with live feed | +| Monitor | `monitor` | Scan files for patterns, send alerts | Rule editor with test & alert history | +| Both | `both` | Viewer + Collector | Viewer UI with collector active | +| All | `all` | All three modes | Viewer UI, all features via API | + +Each mode serves its own dedicated web UI at the root URL. + +--- + +## Viewer + +### What it does + +Reads logs from local files (plain, gzip, bz2, xz, zstd), S3 buckets, and remote collector streams. Displays them in a web UI with filtering, regex search, bookmarks, export, and live tail mode. + +### GUI walkthrough + +1. **Open** http://localhost:9090 +2. **Sidebar** shows all sources grouped by type (Files, S3, Collector Streams, Remote) +3. **Click a source** to filter to just that file/stream +4. **Search bar** — type to filter. Click `.*` for regex, `Aa` for case-sensitive +5. **Level dropdown** — filter by ERROR, WARN, INFO, etc. +6. **Tail button** — auto-scrolls to new lines. New lines flash blue briefly +7. **Right-click** any line for copy, bookmark, filter options +8. **Export** — download visible logs as .txt or .json + +### API examples + +```bash +# List file sources +curl http://localhost:9090/api/sources + +# Add a source +curl -X POST http://localhost:9090/api/sources \ + -H "Content-Type: application/json" \ + -d '{"path": "/var/log/syslog"}' + +# Read all logs +curl http://localhost:9090/api/logs + +# Read specific source +curl "http://localhost:9090/api/logs?source=/var/log/syslog" + +# Read remote collector streams +curl http://localhost:9090/api/remote/streams +curl http://localhost:9090/api/remote/stream/my-app +``` + +### Configuration + +```env +LOG_MODE=viewer +PORT=9090 +LOG_SOURCES=/var/log/syslog,/app/logs/*.log +LOG_THEME=dark +LOG_REFRESH_INTERVAL=2000 +LOG_TAIL_MODE=true +LOG_MAX_LINES=50000 +REMOTE_COLLECTOR_URL=http://collector:8081 +``` + +--- + +## Collector + +### What it does + +Receives logs via HTTP POST and UDP, stores them in named streams, rotates files by size or time, compresses rotated files, and forwards to other targets (HTTP, file, UDP, another stream, console). + +### Sending logs + +```bash +# Plain text (each line = one log entry) +curl -X POST http://localhost:8081/collect/my-app \ + -d "2026-05-04 ERROR Database connection lost" + +# JSON array +curl -X POST http://localhost:8081/collect/my-app/json \ + -H "Content-Type: application/json" \ + -d '["INFO Started", "ERROR Crashed", "WARN Low memory"]' + +# UDP (format: streamId|logline) +echo "my-app|2026-05-04 ERROR disk full" | nc -u localhost 5140 +``` + +Streams are created automatically on first ingest. No pre-configuration needed. + +### GUI walkthrough + +1. **Open** http://localhost:8081 +2. **Overview** shows stream count, total lines, lines/minute +3. **Stream list** — click a stream to filter the live feed +4. **Live Feed tab** — real-time log lines with color coding (red=ERROR, orange=WARN) +5. **Configuration tab** — edit storage, UDP, rotation, compression settings +6. **Forwarding tab** — add/remove forward targets + +### API examples + +```bash +# List streams +curl http://localhost:8081/collector/streams + +# Read a stream +curl http://localhost:8081/collector/stream/my-app + +# Read last 10 lines +curl "http://localhost:8081/collector/stream/my-app?tail=10" + +# Delete a stream +curl -X DELETE http://localhost:8081/collector/stream/my-app + +# Get config +curl http://localhost:8081/collector/config +``` + +### Log rotation + +Rotation triggers on whichever condition is met first: + +| Trigger | Variable | Default | +|---------|----------|---------| +| File size | `COLLECTOR_ROTATION_MAX_SIZE` | 5MB (5242880) | +| File age | `COLLECTOR_ROTATION_MAX_AGE` | 10min (600000) | + +Compression options: `none`, `gzip`, `bzip2`, `xz`, `zstd` + +### Forward targets + +```env +# Forward to HTTP + local file + aggregate stream +COLLECTOR_FORWARD_TARGETS=[ + {"type":"http","url":"http://elk:9200/_bulk","format":"json","batchSize":100}, + {"type":"file","path":"/backup/all.log"}, + {"type":"stream","targetStream":"all-logs"}, + {"type":"console"} +] +``` + +The `stream` type copies logs from any stream into another stream on the same collector. Lines are prefixed with `[fwd:source-stream]`. + +### Configuration + +```env +LOG_MODE=collector +PORT=8081 +COLLECTOR_UDP_ENABLED=true +COLLECTOR_UDP_PORT=5140 +COLLECTOR_STORAGE_DIR=./collector-data +COLLECTOR_MAX_LINES=100000 +COLLECTOR_MAX_STREAMS=50 +COLLECTOR_PERSIST=true +COLLECTOR_ROTATION_MAX_SIZE=5242880 +COLLECTOR_ROTATION_MAX_AGE=600000 +COLLECTOR_ROTATION_COMPRESSION=gzip +COLLECTOR_ROTATION_EXTENSION=.log +``` + +--- + +## Monitor + +### What it does + +Scans log files on a schedule, matches regex patterns, and sends notifications via Telegram, Webhook, Microsoft Teams, or Email. Supports time-range filtering, scheduled scans (continuous, daily, hourly), and automatic gzip attachment when matches exceed a threshold. + +### How scanning works + +1. Every N seconds, the monitor reads all source files for each enabled rule +2. Only files matching the time range are scanned (today, week, month, last N hours) +3. Each line is tested against the regex patterns (match any OR match all) +4. If matches found AND cooldown expired → notifications sent +5. If matches > `maxLinesInMessage` → first N lines inline, full results as .gz attachment + +### GUI walkthrough + +1. **Open** http://localhost:8082 +2. **Click "+ New"** to create a rule +3. **Fill in:** + - Name, sources (file paths, one per line, globs supported) + - Patterns (one regex per line) + - Time range (today, week, month, hours, all) + - Schedule (continuous, daily at HH:MM, every N hours) + - Max lines in notification, cooldown +4. **Add notification channels** — select type, fill fields, click "+ Add Channel" +5. **Click "🧪 Test Notification"** to verify the channel works +6. **Click "💾 Save Rule"** +7. **Click "🧪 Test Now"** to trigger the rule immediately +8. **Alerts** appear in the sidebar — click for full detail with all matched lines + +### Notification channels + +**Telegram:** +- Type: `telegram` +- Fields: Bot Token, Chat ID +- Get a bot token from @BotFather, chat ID from @userinfobot + +**Webhook (Slack, Discord, custom):** +- Type: `webhook` +- Fields: URL + +**Microsoft Teams:** +- Type: `teams` +- Fields: Incoming Webhook URL + +**Email:** +- Type: `email` +- Fields: SMTP Host, SMTP Port, SMTP User, SMTP Password, To address + +### API examples + +```bash +# Create a rule +curl -X POST http://localhost:8082/api/monitor/rules \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Root Access Alert", + "enabled": true, + "sources": ["/var/log/auth.log"], + "patterns": ["root.*login", "su.*root"], + "patternLogic": "any", + "timeRange": {"type": "today"}, + "schedule": {"mode": "interval"}, + "notifications": [{"type":"telegram","botToken":"TOKEN","chatId":"CHAT_ID"}], + "maxLinesInMessage": 20, + "cooldownMs": 300000 + }' + +# List rules +curl http://localhost:8082/api/monitor/rules + +# Trigger a rule manually +curl -X POST http://localhost:8082/api/monitor/trigger/rule-id + +# View alert history +curl http://localhost:8082/api/monitor/results + +# Test a notification channel +curl -X POST http://localhost:8082/api/monitor/test-notification \ + -H "Content-Type: application/json" \ + -d '{"type":"telegram","botToken":"TOKEN","chatId":"CHAT_ID"}' + +# Update a rule +curl -X PUT http://localhost:8082/api/monitor/rule/rule-id \ + -H "Content-Type: application/json" \ + -d '{"name": "Updated Name", "enabled": false}' + +# Delete a rule +curl -X DELETE http://localhost:8082/api/monitor/rule/rule-id +``` + +### Configuration + +```env +LOG_MODE=monitor +PORT=8082 +MONITOR_ENABLED=true +MONITOR_SCAN_INTERVAL=60000 +MONITOR_STORAGE_DIR=./monitor-data +``` + +Rules can be pre-configured via `MONITOR_RULES` env var (JSON array) or created via GUI/API at runtime. + +--- + +## S3 Integration + +### Via GUI + +Click **☁️ S3** in the viewer toolbar. Fill in: name, bucket, prefix, access key, secret key, region, optional custom endpoint (for MinIO). + +### Via environment + +```env +S3_ACCESS_KEY_ID=AKIA... +S3_SECRET_ACCESS_KEY=... +S3_REGION=eu-central-1 +S3_SOURCE_PROD=my-bucket:logs/production/ +S3_SOURCE_STAGING=my-bucket:logs/staging/ +``` + +### Via API + +```bash +curl -X POST http://localhost:9090/api/s3/add \ + -H "Content-Type: application/json" \ + -d '{"name":"prod","bucket":"my-bucket","prefix":"logs/","accessKeyId":"AKIA...","secretAccessKey":"...","region":"eu-central-1"}' + +curl http://localhost:9090/api/s3/sources +curl "http://localhost:9090/api/s3/list?name=prod" +curl "http://localhost:9090/api/s3/read?name=prod&key=logs/app.log" +``` + +For S3-compatible services (MinIO, DigitalOcean Spaces), add `endpoint`: +```env +S3_ENDPOINT=http://minio:9000 +``` + +--- + +## Configuration + +### Priority (highest wins) + +1. Environment variables +2. `logviewer.config.json` (editable via GUI) +3. `.env` file +4. Built-in defaults + +### Full config file example + +```json +{ + "mode": "all", + "port": 8080, + "viewer": { + "sources": ["/var/log/syslog", "/app/logs/*.log"], + "theme": "dark", + "refreshInterval": 2000, + "tailMode": true, + "maxLines": 50000 + }, + "collector": { + "enabled": true, + "storageDir": "./collector-data", + "maxLinesPerStream": 100000, + "maxStreams": 50, + "persistToDisk": true, + "udp": {"enabled": true, "port": 5140}, + "rotation": {"maxSizeBytes": 5242880, "maxAgeMs": 600000, "compression": "gzip", "fileExtension": ".log"}, + "forwardTargets": [{"type": "stream", "targetStream": "all-logs"}] + }, + "monitor": { + "enabled": true, + "scanIntervalMs": 60000, + "rules": [] + }, + "s3": { + "sources": { + "prod": {"bucket": "logs", "prefix": "prod/", "region": "eu-central-1", "accessKeyId": "...", "secretAccessKey": "..."} + } + }, + "remoteCollector": {"url": "http://collector:8081"} +} +``` + +### All environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `LOG_MODE` | `both` | `viewer`, `collector`, `monitor`, `both`, `all` | +| `PORT` | `8080` | HTTP port | +| `HOST` | `0.0.0.0` | Bind address | +| `LOG_SOURCES` | — | Comma-separated file paths/globs | +| `LOG_THEME` | `dark` | `dark` or `light` | +| `LOG_REFRESH_INTERVAL` | `2000` | Auto-refresh ms | +| `LOG_TAIL_MODE` | `true` | Auto-scroll | +| `LOG_MAX_LINES` | `50000` | Max lines per file | +| `COLLECTOR_UDP_ENABLED` | `true` | UDP receiver | +| `COLLECTOR_UDP_PORT` | `5140` | UDP port | +| `COLLECTOR_STORAGE_DIR` | `./collector-data` | Storage path | +| `COLLECTOR_PERSIST` | `true` | Write to disk | +| `COLLECTOR_ROTATION_MAX_SIZE` | `5242880` | Rotate at bytes | +| `COLLECTOR_ROTATION_MAX_AGE` | `600000` | Rotate at ms | +| `COLLECTOR_ROTATION_COMPRESSION` | `gzip` | `none`/`gzip`/`bzip2`/`xz`/`zstd` | +| `COLLECTOR_FORWARD_TARGETS` | `[]` | JSON array | +| `MONITOR_ENABLED` | `true` | Enable scanning | +| `MONITOR_SCAN_INTERVAL` | `60000` | Scan interval ms | +| `MONITOR_RULES` | `[]` | JSON array of rules | +| `REMOTE_COLLECTOR_URL` | — | Remote collector URL | +| `S3_ACCESS_KEY_ID` | — | S3 access key | +| `S3_SECRET_ACCESS_KEY` | — | S3 secret key | +| `S3_REGION` | `us-east-1` | S3 region | +| `S3_ENDPOINT` | — | Custom S3 endpoint | + +--- + +## API Reference + +### General +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/mode` | Current mode info | +| GET | `/api/config` | Active configuration | +| POST | `/api/config/save` | Save config to file | + +### Viewer +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/sources` | List file sources | +| POST | `/api/sources` | Add source `{"path":"..."}` | +| DELETE | `/api/sources?path=...` | Remove source | +| GET | `/api/logs?source=...` | Read logs | +| GET | `/api/remote/streams` | Remote collector streams | +| GET | `/api/remote/stream/:id` | Read remote stream | + +### Collector +| Method | Path | Description | +|--------|------|-------------| +| POST | `/collect/:id` | Ingest plain text | +| POST | `/collect/:id/json` | Ingest JSON | +| GET | `/collector/streams` | List streams | +| GET | `/collector/stream/:id?tail=N` | Read stream | +| GET | `/collector/config` | Collector config | +| DELETE | `/collector/stream/:id` | Delete stream | + +### Monitor +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/monitor/rules` | List rules | +| POST | `/api/monitor/rules` | Create rule | +| PUT | `/api/monitor/rule/:id` | Update rule | +| DELETE | `/api/monitor/rule/:id` | Delete rule | +| POST | `/api/monitor/trigger/:id` | Trigger rule now | +| GET | `/api/monitor/results` | Alert history | +| POST | `/api/monitor/test-notification` | Test a channel | + +### S3 +| Method | Path | Description | +|--------|------|-------------| +| POST | `/api/s3/add` | Add S3 source | +| GET | `/api/s3/sources` | List S3 sources | +| GET | `/api/s3/list?name=...` | List S3 objects | +| GET | `/api/s3/read?name=...&key=...` | Read S3 object | + +--- + +## GUI Walkthroughs + +### Viewer: Search for errors in the last hour + +1. Open http://localhost:9090 +2. Select **"Error"** from the level dropdown +3. Type a search term or regex in the search bar +4. Click `.*` to enable regex mode +5. Results update live. Click **💾 Export** to download + +### Collector: Watch live logs from multiple apps + +1. Open http://localhost:8081 +2. The **Live Feed** tab shows all incoming logs in real-time +3. Click a stream in the sidebar to filter +4. Click **⏸ Pause** to freeze the feed +5. Go to **⚙️ Configuration** to change rotation/compression settings + +### Monitor: Set up a daily security scan + +1. Open http://localhost:8082 +2. Click **"+ New"** +3. Name: "Daily Security Audit" +4. Sources: `/var/log/auth.log` (one per line) +5. Patterns: `root.*login`, `sudo.*COMMAND`, `Failed password` (one per line) +6. Time Range: **Today** +7. Schedule: **Daily at** `07:00` +8. Add Telegram notification: paste bot token and chat ID +9. Click **"🧪 Test Notification"** to verify +10. Click **"💾 Save Rule"** +11. Click **"🧪 Test Now"** to see immediate results + +--- + +## Example Scenarios + +### 1. Development: All-in-one + +```bash +LOG_MODE=both PORT=8080 LOG_SOURCES=./logs/*.log node dist-server/server.js +``` +View logs + receive from apps on one port. + +### 2. Production: Separate services + +```bash +# Server A: Collector +LOG_MODE=collector PORT=8081 COLLECTOR_UDP_PORT=5140 \ + COLLECTOR_ROTATION_COMPRESSION=gzip \ + COLLECTOR_FORWARD_TARGETS='[{"type":"stream","targetStream":"all-logs"}]' \ + node dist-server/server.js + +# Server B: Viewer +LOG_MODE=viewer PORT=9090 REMOTE_COLLECTOR_URL=http://serverA:8081 \ + node dist-server/server.js + +# Server C: Monitor +LOG_MODE=monitor PORT=8082 MONITOR_SCAN_INTERVAL=30000 \ + node dist-server/server.js +``` + +### 3. Security monitoring with Telegram alerts + +```bash +LOG_MODE=monitor PORT=8082 MONITOR_SCAN_INTERVAL=10000 \ + MONITOR_RULES='[{"id":"sec","name":"Security","enabled":true,"sources":["/var/log/auth.log"],"patterns":["root","sudo","Failed"],"patternLogic":"any","timeRange":{"type":"today"},"notifications":[{"type":"telegram","botToken":"YOUR_TOKEN","chatId":"YOUR_CHAT"}],"maxLinesInMessage":20,"cooldownMs":60000}]' \ + node dist-server/server.js +``` + +### 4. Log aggregation with S3 archive + +```bash +LOG_MODE=both PORT=8080 \ + S3_ACCESS_KEY_ID=AKIA... S3_SECRET_ACCESS_KEY=... S3_REGION=eu-central-1 \ + S3_SOURCE_ARCHIVE=my-bucket:logs/ \ + COLLECTOR_FORWARD_TARGETS='[{"type":"file","path":"/archive/all.log"}]' \ + node dist-server/server.js +``` + +--- + +## Docker & CI/CD + +### Build and run + +```bash +docker build -t logmaster . + +# Collector +docker run -d -p 8081:8080 -p 5140:5140/udp -e LOG_MODE=collector logmaster + +# Viewer +docker run -d -p 9090:8080 -e LOG_MODE=viewer -e REMOTE_COLLECTOR_URL=http://collector:8080 logmaster + +# Monitor +docker run -d -p 8082:8080 -e LOG_MODE=monitor -v /var/log:/logs:ro logmaster +``` + +### Docker Compose + +```yaml +services: + collector: + build: . + ports: ["8081:8080", "5140:5140/udp"] + environment: + LOG_MODE: collector + COLLECTOR_UDP_PORT: "5140" + volumes: + - collector-data:/app/collector-data + + viewer: + build: . + ports: ["9090:8080"] + environment: + LOG_MODE: viewer + REMOTE_COLLECTOR_URL: http://collector:8080 + depends_on: [collector] + + monitor: + build: . + ports: ["8082:8080"] + environment: + LOG_MODE: monitor + MONITOR_SCAN_INTERVAL: "30000" + volumes: + - /var/log:/logs:ro + +volumes: + collector-data: +``` + +### CI/CD (Gitea Actions) + +The `.gitea/workflows/ci.yaml` pipeline: +1. Installs dependencies +2. Compiles TypeScript +3. Generates sample logs +4. Starts all 3 services +5. Runs the full integration test suite (41 tests) +6. Builds Docker image +7. Pushes to Gitea container registry + +To enable: add a Gitea Actions runner and set `REGISTRY_USER` / `REGISTRY_PASSWORD` secrets. + +--- + +## Keyboard Shortcuts + +| Key | Action | +|-----|--------| +| `Ctrl+F` | Search | +| `Ctrl+Shift+F` | Regex search | +| `Ctrl+T` | Toggle tail mode | +| `Ctrl+W` | Toggle word wrap | +| `Ctrl+G` | Go to line | +| `Ctrl+Shift+T` | Toggle theme | +| `Ctrl+=` / `Ctrl+-` | Zoom in/out | +| `Ctrl+0` | Reset zoom | +| `Home` / `End` | Scroll top/bottom | +| `Escape` | Clear search / close modals | + +--- + +## Supported Log Formats + +| Format | Extensions | Decompression | +|--------|-----------|---------------| +| Plain text | `.log`, `.txt`, no extension | Direct read | +| Gzip | `.gz` | Node.js zlib (built-in) | +| Bzip2 | `.bz2` | `bzcat` CLI | +| XZ | `.xz` | `xzcat` CLI | +| Zstandard | `.zst` | `zstdcat` CLI | + +--- + +## License + +MIT diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4524fc2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +FROM node:20-alpine + +WORKDIR /app + +# Install compression tools for bz2, xz, zstd support +RUN apk add --no-cache bzip2 xz zstd + +# Copy package files and install deps +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev 2>/dev/null || npm install --omit=dev + +# Copy TypeScript config and source +COPY tsconfig.server.json ./ +COPY src/server/ ./src/server/ +COPY src/web/ ./src/web/ + +# Install typescript for build, compile, then remove +RUN npm install typescript@5.7.2 && \ + npx tsc -p tsconfig.server.json && \ + npm uninstall typescript + +# Copy sample logs +COPY sample-logs/ ./sample-logs/ + +# Generate sample logs +RUN node sample-logs/generate-logs.js + +# Environment defaults +ENV LOG_SOURCES=/app/sample-logs/app.log,/app/sample-logs/nginx-access.log,/app/sample-logs/syslog,/app/sample-logs/archived.log.gz +ENV LOG_THEME=dark +ENV LOG_REFRESH_INTERVAL=3000 +ENV LOG_TAIL_MODE=true +ENV PORT=8080 +ENV HOST=0.0.0.0 + +EXPOSE 8080 + +CMD ["node", "dist-server/server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..eb58533 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# Log Viewer + +A powerful log management tool with four operating modes: Viewer, Collector, Monitor, and combined. Features web UIs, S3 integration, UDP ingestion, log rotation, pattern-based alerting, and multi-channel notifications. + +## Quick Start + +```bash +npm install +npm run build +node dist-server/server.js +``` + +Open http://localhost:8080 in your browser. + +## Modes + +| Mode | Command | Port | Description | +|------|---------|------|-------------| +| Both (default) | `node dist-server/server.js` | 8080 | Viewer + Collector | +| Viewer | `cp .env.viewer .env && node dist-server/server.js` | 9090 | Read & display logs | +| Collector | `cp .env.collector .env && node dist-server/server.js` | 8081 | Receive & store logs | +| Monitor | `cp .env.monitor .env && node dist-server/server.js` | 8082 | Scan & alert on patterns | + +## Documentation + +- **[Full Documentation](DOCS.md)** — Complete reference +- **[Viewer Guide](docs/VIEWER.md)** — How to use the log viewer +- **[Collector Guide](docs/COLLECTOR.md)** — How to set up log collection +- **[Monitor Guide](docs/MONITOR.md)** — How to configure pattern alerts + +## Features + +- Multi-source: local files, gzip/bz2/xz/zst, S3, UDP, HTTP +- Real-time log streaming with live highlighting +- Regex search with level filtering +- Log rotation (size + time based, with compression) +- Forward to HTTP, file, UDP, or console +- Pattern monitoring with Telegram, Webhook, Teams, Email alerts +- Scheduled scans (continuous, daily, hourly) +- Dark/Light theme, keyboard shortcuts, export + +## License + +MIT diff --git a/collector-data/_streams.json b/collector-data/_streams.json new file mode 100644 index 0000000..34dd28b --- /dev/null +++ b/collector-data/_streams.json @@ -0,0 +1,58 @@ +[ + { + "id": "web-app", + "name": "web-app", + "createdAt": "2026-05-01T19:00:40.860Z", + "lineCount": 1105, + "lastReceived": "2026-05-04T11:50:15.405Z", + "currentFileSize": 66937 + }, + { + "id": "auth-service", + "name": "auth-service", + "createdAt": "2026-05-01T19:00:40.862Z", + "lineCount": 0, + "lastReceived": "2026-05-02T20:28:12.250Z", + "currentFileSize": 0 + }, + { + "id": "payment-api", + "name": "payment-api", + "createdAt": "2026-05-01T19:00:41.742Z", + "lineCount": 0, + "lastReceived": "2026-05-02T20:28:12.868Z", + "currentFileSize": 0 + }, + { + "id": "worker-1", + "name": "worker-1", + "createdAt": "2026-05-01T19:00:43.603Z", + "lineCount": 0, + "lastReceived": "2026-05-02T20:28:10.699Z", + "currentFileSize": 0 + }, + { + "id": "app-server", + "name": "app-server", + "createdAt": "2026-05-04T08:57:18.300Z", + "lineCount": 0, + "lastReceived": "2026-05-04T08:57:18.300Z", + "currentFileSize": 0 + }, + { + "id": "payment-svc", + "name": "payment-svc", + "createdAt": "2026-05-04T08:57:18.302Z", + "lineCount": 0, + "lastReceived": "2026-05-04T08:57:18.302Z", + "currentFileSize": 0 + }, + { + "id": "auth-svc", + "name": "auth-svc", + "createdAt": "2026-05-04T08:57:18.311Z", + "lineCount": 0, + "lastReceived": "2026-05-04T08:57:18.312Z", + "currentFileSize": 0 + } +] \ No newline at end of file diff --git a/collector-data/app-server_2026-05-04T09-07-20-156Z.log.gz b/collector-data/app-server_2026-05-04T09-07-20-156Z.log.gz new file mode 100644 index 0000000..dbb28bf Binary files /dev/null and b/collector-data/app-server_2026-05-04T09-07-20-156Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T14-43-01-876Z.log.gz b/collector-data/auth-service_2026-05-02T14-43-01-876Z.log.gz new file mode 100644 index 0000000..9d0a38b Binary files /dev/null and b/collector-data/auth-service_2026-05-02T14-43-01-876Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T14-53-02-035Z.log.gz b/collector-data/auth-service_2026-05-02T14-53-02-035Z.log.gz new file mode 100644 index 0000000..6a8bbce Binary files /dev/null and b/collector-data/auth-service_2026-05-02T14-53-02-035Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T15-03-02-176Z.log.gz b/collector-data/auth-service_2026-05-02T15-03-02-176Z.log.gz new file mode 100644 index 0000000..e179525 Binary files /dev/null and b/collector-data/auth-service_2026-05-02T15-03-02-176Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T15-13-02-355Z.log.gz b/collector-data/auth-service_2026-05-02T15-13-02-355Z.log.gz new file mode 100644 index 0000000..1757add Binary files /dev/null and b/collector-data/auth-service_2026-05-02T15-13-02-355Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T15-23-02-525Z.log.gz b/collector-data/auth-service_2026-05-02T15-23-02-525Z.log.gz new file mode 100644 index 0000000..786f298 Binary files /dev/null and b/collector-data/auth-service_2026-05-02T15-23-02-525Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T15-33-02-728Z.log.gz b/collector-data/auth-service_2026-05-02T15-33-02-728Z.log.gz new file mode 100644 index 0000000..f8d4be6 Binary files /dev/null and b/collector-data/auth-service_2026-05-02T15-33-02-728Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T15-43-02-883Z.log.gz b/collector-data/auth-service_2026-05-02T15-43-02-883Z.log.gz new file mode 100644 index 0000000..31baaa0 Binary files /dev/null and b/collector-data/auth-service_2026-05-02T15-43-02-883Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T15-53-03-023Z.log.gz b/collector-data/auth-service_2026-05-02T15-53-03-023Z.log.gz new file mode 100644 index 0000000..1abfa8e Binary files /dev/null and b/collector-data/auth-service_2026-05-02T15-53-03-023Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T16-03-03-174Z.log.gz b/collector-data/auth-service_2026-05-02T16-03-03-174Z.log.gz new file mode 100644 index 0000000..187f2d1 Binary files /dev/null and b/collector-data/auth-service_2026-05-02T16-03-03-174Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T16-13-03-324Z.log.gz b/collector-data/auth-service_2026-05-02T16-13-03-324Z.log.gz new file mode 100644 index 0000000..1fe843b Binary files /dev/null and b/collector-data/auth-service_2026-05-02T16-13-03-324Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T16-23-03-499Z.log.gz b/collector-data/auth-service_2026-05-02T16-23-03-499Z.log.gz new file mode 100644 index 0000000..f3e2993 Binary files /dev/null and b/collector-data/auth-service_2026-05-02T16-23-03-499Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T16-33-03-671Z.log.gz b/collector-data/auth-service_2026-05-02T16-33-03-671Z.log.gz new file mode 100644 index 0000000..d577b45 Binary files /dev/null and b/collector-data/auth-service_2026-05-02T16-33-03-671Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T16-43-03-772Z.log.gz b/collector-data/auth-service_2026-05-02T16-43-03-772Z.log.gz new file mode 100644 index 0000000..98fba73 Binary files /dev/null and b/collector-data/auth-service_2026-05-02T16-43-03-772Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T16-53-03-961Z.log.gz b/collector-data/auth-service_2026-05-02T16-53-03-961Z.log.gz new file mode 100644 index 0000000..1d49cbd Binary files /dev/null and b/collector-data/auth-service_2026-05-02T16-53-03-961Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T17-03-04-098Z.log.gz b/collector-data/auth-service_2026-05-02T17-03-04-098Z.log.gz new file mode 100644 index 0000000..6629c94 Binary files /dev/null and b/collector-data/auth-service_2026-05-02T17-03-04-098Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T17-13-04-225Z.log.gz b/collector-data/auth-service_2026-05-02T17-13-04-225Z.log.gz new file mode 100644 index 0000000..64a7746 Binary files /dev/null and b/collector-data/auth-service_2026-05-02T17-13-04-225Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T17-23-04-392Z.log.gz b/collector-data/auth-service_2026-05-02T17-23-04-392Z.log.gz new file mode 100644 index 0000000..43eb79d Binary files /dev/null and b/collector-data/auth-service_2026-05-02T17-23-04-392Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T17-33-04-572Z.log.gz b/collector-data/auth-service_2026-05-02T17-33-04-572Z.log.gz new file mode 100644 index 0000000..61d0291 Binary files /dev/null and b/collector-data/auth-service_2026-05-02T17-33-04-572Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T17-43-04-725Z.log.gz b/collector-data/auth-service_2026-05-02T17-43-04-725Z.log.gz new file mode 100644 index 0000000..378239e Binary files /dev/null and b/collector-data/auth-service_2026-05-02T17-43-04-725Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T17-53-04-876Z.log.gz b/collector-data/auth-service_2026-05-02T17-53-04-876Z.log.gz new file mode 100644 index 0000000..6733ec7 Binary files /dev/null and b/collector-data/auth-service_2026-05-02T17-53-04-876Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T18-03-04-988Z.log.gz b/collector-data/auth-service_2026-05-02T18-03-04-988Z.log.gz new file mode 100644 index 0000000..e4b2b5c Binary files /dev/null and b/collector-data/auth-service_2026-05-02T18-03-04-988Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T18-13-05-086Z.log.gz b/collector-data/auth-service_2026-05-02T18-13-05-086Z.log.gz new file mode 100644 index 0000000..4a8c621 Binary files /dev/null and b/collector-data/auth-service_2026-05-02T18-13-05-086Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T18-23-05-266Z.log.gz b/collector-data/auth-service_2026-05-02T18-23-05-266Z.log.gz new file mode 100644 index 0000000..9ff53f1 Binary files /dev/null and b/collector-data/auth-service_2026-05-02T18-23-05-266Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T18-33-05-422Z.log.gz b/collector-data/auth-service_2026-05-02T18-33-05-422Z.log.gz new file mode 100644 index 0000000..fe0638d Binary files /dev/null and b/collector-data/auth-service_2026-05-02T18-33-05-422Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T18-43-05-548Z.log.gz b/collector-data/auth-service_2026-05-02T18-43-05-548Z.log.gz new file mode 100644 index 0000000..5dbf4fe Binary files /dev/null and b/collector-data/auth-service_2026-05-02T18-43-05-548Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T18-53-05-697Z.log.gz b/collector-data/auth-service_2026-05-02T18-53-05-697Z.log.gz new file mode 100644 index 0000000..b3fe49f Binary files /dev/null and b/collector-data/auth-service_2026-05-02T18-53-05-697Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T19-03-05-850Z.log.gz b/collector-data/auth-service_2026-05-02T19-03-05-850Z.log.gz new file mode 100644 index 0000000..48d80dd Binary files /dev/null and b/collector-data/auth-service_2026-05-02T19-03-05-850Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T19-13-06-025Z.log.gz b/collector-data/auth-service_2026-05-02T19-13-06-025Z.log.gz new file mode 100644 index 0000000..d640ce1 Binary files /dev/null and b/collector-data/auth-service_2026-05-02T19-13-06-025Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T19-23-06-171Z.log.gz b/collector-data/auth-service_2026-05-02T19-23-06-171Z.log.gz new file mode 100644 index 0000000..def9269 Binary files /dev/null and b/collector-data/auth-service_2026-05-02T19-23-06-171Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T19-33-06-322Z.log.gz b/collector-data/auth-service_2026-05-02T19-33-06-322Z.log.gz new file mode 100644 index 0000000..6a6e3cf Binary files /dev/null and b/collector-data/auth-service_2026-05-02T19-33-06-322Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T19-43-06-482Z.log.gz b/collector-data/auth-service_2026-05-02T19-43-06-482Z.log.gz new file mode 100644 index 0000000..266ce18 Binary files /dev/null and b/collector-data/auth-service_2026-05-02T19-43-06-482Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T19-53-06-639Z.log.gz b/collector-data/auth-service_2026-05-02T19-53-06-639Z.log.gz new file mode 100644 index 0000000..006a035 Binary files /dev/null and b/collector-data/auth-service_2026-05-02T19-53-06-639Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T20-03-06-777Z.log.gz b/collector-data/auth-service_2026-05-02T20-03-06-777Z.log.gz new file mode 100644 index 0000000..3ea0f87 Binary files /dev/null and b/collector-data/auth-service_2026-05-02T20-03-06-777Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T20-13-06-961Z.log.gz b/collector-data/auth-service_2026-05-02T20-13-06-961Z.log.gz new file mode 100644 index 0000000..7877be7 Binary files /dev/null and b/collector-data/auth-service_2026-05-02T20-13-06-961Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-02T20-23-07-105Z.log.gz b/collector-data/auth-service_2026-05-02T20-23-07-105Z.log.gz new file mode 100644 index 0000000..dce149c Binary files /dev/null and b/collector-data/auth-service_2026-05-02T20-23-07-105Z.log.gz differ diff --git a/collector-data/auth-service_2026-05-04T08-50-49-889Z.log.gz b/collector-data/auth-service_2026-05-04T08-50-49-889Z.log.gz new file mode 100644 index 0000000..85798d3 Binary files /dev/null and b/collector-data/auth-service_2026-05-04T08-50-49-889Z.log.gz differ diff --git a/collector-data/auth-svc_2026-05-04T09-07-20-158Z.log.gz b/collector-data/auth-svc_2026-05-04T09-07-20-158Z.log.gz new file mode 100644 index 0000000..6e7b74e Binary files /dev/null and b/collector-data/auth-svc_2026-05-04T09-07-20-158Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T14-43-01-877Z.log.gz b/collector-data/payment-api_2026-05-02T14-43-01-877Z.log.gz new file mode 100644 index 0000000..d11be4b Binary files /dev/null and b/collector-data/payment-api_2026-05-02T14-43-01-877Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T14-53-02-036Z.log.gz b/collector-data/payment-api_2026-05-02T14-53-02-036Z.log.gz new file mode 100644 index 0000000..4048e86 Binary files /dev/null and b/collector-data/payment-api_2026-05-02T14-53-02-036Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T15-03-02-177Z.log.gz b/collector-data/payment-api_2026-05-02T15-03-02-177Z.log.gz new file mode 100644 index 0000000..1e39e83 Binary files /dev/null and b/collector-data/payment-api_2026-05-02T15-03-02-177Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T15-13-02-356Z.log.gz b/collector-data/payment-api_2026-05-02T15-13-02-356Z.log.gz new file mode 100644 index 0000000..770792d Binary files /dev/null and b/collector-data/payment-api_2026-05-02T15-13-02-356Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T15-23-02-526Z.log.gz b/collector-data/payment-api_2026-05-02T15-23-02-526Z.log.gz new file mode 100644 index 0000000..1b94588 Binary files /dev/null and b/collector-data/payment-api_2026-05-02T15-23-02-526Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T15-33-02-729Z.log.gz b/collector-data/payment-api_2026-05-02T15-33-02-729Z.log.gz new file mode 100644 index 0000000..8e0b22f Binary files /dev/null and b/collector-data/payment-api_2026-05-02T15-33-02-729Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T15-43-02-884Z.log.gz b/collector-data/payment-api_2026-05-02T15-43-02-884Z.log.gz new file mode 100644 index 0000000..e21308c Binary files /dev/null and b/collector-data/payment-api_2026-05-02T15-43-02-884Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T15-53-03-024Z.log.gz b/collector-data/payment-api_2026-05-02T15-53-03-024Z.log.gz new file mode 100644 index 0000000..866bcdd Binary files /dev/null and b/collector-data/payment-api_2026-05-02T15-53-03-024Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T16-03-03-175Z.log.gz b/collector-data/payment-api_2026-05-02T16-03-03-175Z.log.gz new file mode 100644 index 0000000..42fec6f Binary files /dev/null and b/collector-data/payment-api_2026-05-02T16-03-03-175Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T16-13-03-325Z.log.gz b/collector-data/payment-api_2026-05-02T16-13-03-325Z.log.gz new file mode 100644 index 0000000..92c0a56 Binary files /dev/null and b/collector-data/payment-api_2026-05-02T16-13-03-325Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T16-23-03-500Z.log.gz b/collector-data/payment-api_2026-05-02T16-23-03-500Z.log.gz new file mode 100644 index 0000000..da80612 Binary files /dev/null and b/collector-data/payment-api_2026-05-02T16-23-03-500Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T16-33-03-672Z.log.gz b/collector-data/payment-api_2026-05-02T16-33-03-672Z.log.gz new file mode 100644 index 0000000..995c0d4 Binary files /dev/null and b/collector-data/payment-api_2026-05-02T16-33-03-672Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T16-43-03-773Z.log.gz b/collector-data/payment-api_2026-05-02T16-43-03-773Z.log.gz new file mode 100644 index 0000000..193ea00 Binary files /dev/null and b/collector-data/payment-api_2026-05-02T16-43-03-773Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T16-53-03-962Z.log.gz b/collector-data/payment-api_2026-05-02T16-53-03-962Z.log.gz new file mode 100644 index 0000000..e7adc65 Binary files /dev/null and b/collector-data/payment-api_2026-05-02T16-53-03-962Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T17-03-04-099Z.log.gz b/collector-data/payment-api_2026-05-02T17-03-04-099Z.log.gz new file mode 100644 index 0000000..32a80ac Binary files /dev/null and b/collector-data/payment-api_2026-05-02T17-03-04-099Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T17-13-04-226Z.log.gz b/collector-data/payment-api_2026-05-02T17-13-04-226Z.log.gz new file mode 100644 index 0000000..14277be Binary files /dev/null and b/collector-data/payment-api_2026-05-02T17-13-04-226Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T17-23-04-393Z.log.gz b/collector-data/payment-api_2026-05-02T17-23-04-393Z.log.gz new file mode 100644 index 0000000..2262cd1 Binary files /dev/null and b/collector-data/payment-api_2026-05-02T17-23-04-393Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T17-33-04-573Z.log.gz b/collector-data/payment-api_2026-05-02T17-33-04-573Z.log.gz new file mode 100644 index 0000000..f2030b0 Binary files /dev/null and b/collector-data/payment-api_2026-05-02T17-33-04-573Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T17-43-04-726Z.log.gz b/collector-data/payment-api_2026-05-02T17-43-04-726Z.log.gz new file mode 100644 index 0000000..80fa612 Binary files /dev/null and b/collector-data/payment-api_2026-05-02T17-43-04-726Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T17-53-04-877Z.log.gz b/collector-data/payment-api_2026-05-02T17-53-04-877Z.log.gz new file mode 100644 index 0000000..cef7361 Binary files /dev/null and b/collector-data/payment-api_2026-05-02T17-53-04-877Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T18-03-04-989Z.log.gz b/collector-data/payment-api_2026-05-02T18-03-04-989Z.log.gz new file mode 100644 index 0000000..9f36d18 Binary files /dev/null and b/collector-data/payment-api_2026-05-02T18-03-04-989Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T18-13-05-087Z.log.gz b/collector-data/payment-api_2026-05-02T18-13-05-087Z.log.gz new file mode 100644 index 0000000..f459cff Binary files /dev/null and b/collector-data/payment-api_2026-05-02T18-13-05-087Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T18-23-05-267Z.log.gz b/collector-data/payment-api_2026-05-02T18-23-05-267Z.log.gz new file mode 100644 index 0000000..cda33d3 Binary files /dev/null and b/collector-data/payment-api_2026-05-02T18-23-05-267Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T18-33-05-423Z.log.gz b/collector-data/payment-api_2026-05-02T18-33-05-423Z.log.gz new file mode 100644 index 0000000..c742344 Binary files /dev/null and b/collector-data/payment-api_2026-05-02T18-33-05-423Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T18-43-05-549Z.log.gz b/collector-data/payment-api_2026-05-02T18-43-05-549Z.log.gz new file mode 100644 index 0000000..683333c Binary files /dev/null and b/collector-data/payment-api_2026-05-02T18-43-05-549Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T18-53-05-698Z.log.gz b/collector-data/payment-api_2026-05-02T18-53-05-698Z.log.gz new file mode 100644 index 0000000..1db16ae Binary files /dev/null and b/collector-data/payment-api_2026-05-02T18-53-05-698Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T19-03-05-851Z.log.gz b/collector-data/payment-api_2026-05-02T19-03-05-851Z.log.gz new file mode 100644 index 0000000..65622d4 Binary files /dev/null and b/collector-data/payment-api_2026-05-02T19-03-05-851Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T19-13-06-026Z.log.gz b/collector-data/payment-api_2026-05-02T19-13-06-026Z.log.gz new file mode 100644 index 0000000..a02cd56 Binary files /dev/null and b/collector-data/payment-api_2026-05-02T19-13-06-026Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T19-23-06-172Z.log.gz b/collector-data/payment-api_2026-05-02T19-23-06-172Z.log.gz new file mode 100644 index 0000000..65c035d Binary files /dev/null and b/collector-data/payment-api_2026-05-02T19-23-06-172Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T19-33-06-323Z.log.gz b/collector-data/payment-api_2026-05-02T19-33-06-323Z.log.gz new file mode 100644 index 0000000..a3ed347 Binary files /dev/null and b/collector-data/payment-api_2026-05-02T19-33-06-323Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T19-43-06-483Z.log.gz b/collector-data/payment-api_2026-05-02T19-43-06-483Z.log.gz new file mode 100644 index 0000000..21b27fe Binary files /dev/null and b/collector-data/payment-api_2026-05-02T19-43-06-483Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T19-53-06-640Z.log.gz b/collector-data/payment-api_2026-05-02T19-53-06-640Z.log.gz new file mode 100644 index 0000000..b2659df Binary files /dev/null and b/collector-data/payment-api_2026-05-02T19-53-06-640Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T20-03-06-778Z.log.gz b/collector-data/payment-api_2026-05-02T20-03-06-778Z.log.gz new file mode 100644 index 0000000..9745b82 Binary files /dev/null and b/collector-data/payment-api_2026-05-02T20-03-06-778Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T20-13-06-962Z.log.gz b/collector-data/payment-api_2026-05-02T20-13-06-962Z.log.gz new file mode 100644 index 0000000..c53dfea Binary files /dev/null and b/collector-data/payment-api_2026-05-02T20-13-06-962Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-02T20-23-07-107Z.log.gz b/collector-data/payment-api_2026-05-02T20-23-07-107Z.log.gz new file mode 100644 index 0000000..2ef3e5b Binary files /dev/null and b/collector-data/payment-api_2026-05-02T20-23-07-107Z.log.gz differ diff --git a/collector-data/payment-api_2026-05-04T08-50-49-890Z.log.gz b/collector-data/payment-api_2026-05-04T08-50-49-890Z.log.gz new file mode 100644 index 0000000..0a9ca41 Binary files /dev/null and b/collector-data/payment-api_2026-05-04T08-50-49-890Z.log.gz differ diff --git a/collector-data/payment-svc_2026-05-04T09-07-20-157Z.log.gz b/collector-data/payment-svc_2026-05-04T09-07-20-157Z.log.gz new file mode 100644 index 0000000..b1b6f40 Binary files /dev/null and b/collector-data/payment-svc_2026-05-04T09-07-20-157Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T14-43-01-875Z.log.gz b/collector-data/web-app_2026-05-02T14-43-01-875Z.log.gz new file mode 100644 index 0000000..369fee1 Binary files /dev/null and b/collector-data/web-app_2026-05-02T14-43-01-875Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T14-53-02-033Z.log.gz b/collector-data/web-app_2026-05-02T14-53-02-033Z.log.gz new file mode 100644 index 0000000..fe979c9 Binary files /dev/null and b/collector-data/web-app_2026-05-02T14-53-02-033Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T15-03-02-174Z.log.gz b/collector-data/web-app_2026-05-02T15-03-02-174Z.log.gz new file mode 100644 index 0000000..70adef0 Binary files /dev/null and b/collector-data/web-app_2026-05-02T15-03-02-174Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T15-13-02-353Z.log.gz b/collector-data/web-app_2026-05-02T15-13-02-353Z.log.gz new file mode 100644 index 0000000..ee8cafc Binary files /dev/null and b/collector-data/web-app_2026-05-02T15-13-02-353Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T15-23-02-523Z.log.gz b/collector-data/web-app_2026-05-02T15-23-02-523Z.log.gz new file mode 100644 index 0000000..f29bc80 Binary files /dev/null and b/collector-data/web-app_2026-05-02T15-23-02-523Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T15-33-02-727Z.log.gz b/collector-data/web-app_2026-05-02T15-33-02-727Z.log.gz new file mode 100644 index 0000000..c62b0c6 Binary files /dev/null and b/collector-data/web-app_2026-05-02T15-33-02-727Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T15-43-02-882Z.log.gz b/collector-data/web-app_2026-05-02T15-43-02-882Z.log.gz new file mode 100644 index 0000000..1a1a804 Binary files /dev/null and b/collector-data/web-app_2026-05-02T15-43-02-882Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T15-53-03-021Z.log.gz b/collector-data/web-app_2026-05-02T15-53-03-021Z.log.gz new file mode 100644 index 0000000..d7b391a Binary files /dev/null and b/collector-data/web-app_2026-05-02T15-53-03-021Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T16-03-03-173Z.log.gz b/collector-data/web-app_2026-05-02T16-03-03-173Z.log.gz new file mode 100644 index 0000000..a18781d Binary files /dev/null and b/collector-data/web-app_2026-05-02T16-03-03-173Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T16-13-03-323Z.log.gz b/collector-data/web-app_2026-05-02T16-13-03-323Z.log.gz new file mode 100644 index 0000000..326c709 Binary files /dev/null and b/collector-data/web-app_2026-05-02T16-13-03-323Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T16-23-03-498Z.log.gz b/collector-data/web-app_2026-05-02T16-23-03-498Z.log.gz new file mode 100644 index 0000000..0dba7bd Binary files /dev/null and b/collector-data/web-app_2026-05-02T16-23-03-498Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T16-33-03-670Z.log.gz b/collector-data/web-app_2026-05-02T16-33-03-670Z.log.gz new file mode 100644 index 0000000..ef3c4d7 Binary files /dev/null and b/collector-data/web-app_2026-05-02T16-33-03-670Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T16-43-03-771Z.log.gz b/collector-data/web-app_2026-05-02T16-43-03-771Z.log.gz new file mode 100644 index 0000000..8a01393 Binary files /dev/null and b/collector-data/web-app_2026-05-02T16-43-03-771Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T16-53-03-959Z.log.gz b/collector-data/web-app_2026-05-02T16-53-03-959Z.log.gz new file mode 100644 index 0000000..de53425 Binary files /dev/null and b/collector-data/web-app_2026-05-02T16-53-03-959Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T17-03-04-097Z.log.gz b/collector-data/web-app_2026-05-02T17-03-04-097Z.log.gz new file mode 100644 index 0000000..6077717 Binary files /dev/null and b/collector-data/web-app_2026-05-02T17-03-04-097Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T17-13-04-223Z.log.gz b/collector-data/web-app_2026-05-02T17-13-04-223Z.log.gz new file mode 100644 index 0000000..5bdaa40 Binary files /dev/null and b/collector-data/web-app_2026-05-02T17-13-04-223Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T17-23-04-391Z.log.gz b/collector-data/web-app_2026-05-02T17-23-04-391Z.log.gz new file mode 100644 index 0000000..0f485f1 Binary files /dev/null and b/collector-data/web-app_2026-05-02T17-23-04-391Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T17-33-04-570Z.log.gz b/collector-data/web-app_2026-05-02T17-33-04-570Z.log.gz new file mode 100644 index 0000000..8cf9eb9 Binary files /dev/null and b/collector-data/web-app_2026-05-02T17-33-04-570Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T17-43-04-723Z.log.gz b/collector-data/web-app_2026-05-02T17-43-04-723Z.log.gz new file mode 100644 index 0000000..60ded8e Binary files /dev/null and b/collector-data/web-app_2026-05-02T17-43-04-723Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T17-53-04-875Z.log.gz b/collector-data/web-app_2026-05-02T17-53-04-875Z.log.gz new file mode 100644 index 0000000..0d10a21 Binary files /dev/null and b/collector-data/web-app_2026-05-02T17-53-04-875Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T18-03-04-986Z.log.gz b/collector-data/web-app_2026-05-02T18-03-04-986Z.log.gz new file mode 100644 index 0000000..200fa26 Binary files /dev/null and b/collector-data/web-app_2026-05-02T18-03-04-986Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T18-13-05-085Z.log.gz b/collector-data/web-app_2026-05-02T18-13-05-085Z.log.gz new file mode 100644 index 0000000..ced415c Binary files /dev/null and b/collector-data/web-app_2026-05-02T18-13-05-085Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T18-23-05-264Z.log.gz b/collector-data/web-app_2026-05-02T18-23-05-264Z.log.gz new file mode 100644 index 0000000..f50e894 Binary files /dev/null and b/collector-data/web-app_2026-05-02T18-23-05-264Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T18-33-05-420Z.log.gz b/collector-data/web-app_2026-05-02T18-33-05-420Z.log.gz new file mode 100644 index 0000000..ee5b32b Binary files /dev/null and b/collector-data/web-app_2026-05-02T18-33-05-420Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T18-43-05-547Z.log.gz b/collector-data/web-app_2026-05-02T18-43-05-547Z.log.gz new file mode 100644 index 0000000..aa98484 Binary files /dev/null and b/collector-data/web-app_2026-05-02T18-43-05-547Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T18-53-05-696Z.log.gz b/collector-data/web-app_2026-05-02T18-53-05-696Z.log.gz new file mode 100644 index 0000000..a738bc5 Binary files /dev/null and b/collector-data/web-app_2026-05-02T18-53-05-696Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T19-03-05-848Z.log.gz b/collector-data/web-app_2026-05-02T19-03-05-848Z.log.gz new file mode 100644 index 0000000..c107637 Binary files /dev/null and b/collector-data/web-app_2026-05-02T19-03-05-848Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T19-13-06-024Z.log.gz b/collector-data/web-app_2026-05-02T19-13-06-024Z.log.gz new file mode 100644 index 0000000..a6902d5 Binary files /dev/null and b/collector-data/web-app_2026-05-02T19-13-06-024Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T19-23-06-170Z.log.gz b/collector-data/web-app_2026-05-02T19-23-06-170Z.log.gz new file mode 100644 index 0000000..1bedb30 Binary files /dev/null and b/collector-data/web-app_2026-05-02T19-23-06-170Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T19-33-06-320Z.log.gz b/collector-data/web-app_2026-05-02T19-33-06-320Z.log.gz new file mode 100644 index 0000000..0c4ade6 Binary files /dev/null and b/collector-data/web-app_2026-05-02T19-33-06-320Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T19-43-06-480Z.log.gz b/collector-data/web-app_2026-05-02T19-43-06-480Z.log.gz new file mode 100644 index 0000000..42f04a1 Binary files /dev/null and b/collector-data/web-app_2026-05-02T19-43-06-480Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T19-53-06-638Z.log.gz b/collector-data/web-app_2026-05-02T19-53-06-638Z.log.gz new file mode 100644 index 0000000..ce10e23 Binary files /dev/null and b/collector-data/web-app_2026-05-02T19-53-06-638Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T20-03-06-775Z.log.gz b/collector-data/web-app_2026-05-02T20-03-06-775Z.log.gz new file mode 100644 index 0000000..08aeb43 Binary files /dev/null and b/collector-data/web-app_2026-05-02T20-03-06-775Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T20-13-06-960Z.log.gz b/collector-data/web-app_2026-05-02T20-13-06-960Z.log.gz new file mode 100644 index 0000000..60eab74 Binary files /dev/null and b/collector-data/web-app_2026-05-02T20-13-06-960Z.log.gz differ diff --git a/collector-data/web-app_2026-05-02T20-23-07-104Z.log.gz b/collector-data/web-app_2026-05-02T20-23-07-104Z.log.gz new file mode 100644 index 0000000..959c332 Binary files /dev/null and b/collector-data/web-app_2026-05-02T20-23-07-104Z.log.gz differ diff --git a/collector-data/web-app_2026-05-04T08-50-49-884Z.log.gz b/collector-data/web-app_2026-05-04T08-50-49-884Z.log.gz new file mode 100644 index 0000000..5d7a920 Binary files /dev/null and b/collector-data/web-app_2026-05-04T08-50-49-884Z.log.gz differ diff --git a/collector-data/web-app_2026-05-04T09-00-50-036Z.log.gz b/collector-data/web-app_2026-05-04T09-00-50-036Z.log.gz new file mode 100644 index 0000000..7f26b35 Binary files /dev/null and b/collector-data/web-app_2026-05-04T09-00-50-036Z.log.gz differ diff --git a/collector-data/web-app_2026-05-04T09-10-50-193Z.log.gz b/collector-data/web-app_2026-05-04T09-10-50-193Z.log.gz new file mode 100644 index 0000000..09a4568 Binary files /dev/null and b/collector-data/web-app_2026-05-04T09-10-50-193Z.log.gz differ diff --git a/collector-data/web-app_2026-05-04T09-20-50-309Z.log.gz b/collector-data/web-app_2026-05-04T09-20-50-309Z.log.gz new file mode 100644 index 0000000..311bef3 Binary files /dev/null and b/collector-data/web-app_2026-05-04T09-20-50-309Z.log.gz differ diff --git a/collector-data/web-app_2026-05-04T09-30-50-437Z.log.gz b/collector-data/web-app_2026-05-04T09-30-50-437Z.log.gz new file mode 100644 index 0000000..36a86ce Binary files /dev/null and b/collector-data/web-app_2026-05-04T09-30-50-437Z.log.gz differ diff --git a/collector-data/web-app_2026-05-04T09-40-50-573Z.log.gz b/collector-data/web-app_2026-05-04T09-40-50-573Z.log.gz new file mode 100644 index 0000000..3831f9e Binary files /dev/null and b/collector-data/web-app_2026-05-04T09-40-50-573Z.log.gz differ diff --git a/collector-data/web-app_2026-05-04T09-50-50-730Z.log.gz b/collector-data/web-app_2026-05-04T09-50-50-730Z.log.gz new file mode 100644 index 0000000..74ac0ce Binary files /dev/null and b/collector-data/web-app_2026-05-04T09-50-50-730Z.log.gz differ diff --git a/collector-data/web-app_2026-05-04T10-00-50-870Z.log.gz b/collector-data/web-app_2026-05-04T10-00-50-870Z.log.gz new file mode 100644 index 0000000..7d936c2 Binary files /dev/null and b/collector-data/web-app_2026-05-04T10-00-50-870Z.log.gz differ diff --git a/collector-data/web-app_2026-05-04T10-10-51-015Z.log.gz b/collector-data/web-app_2026-05-04T10-10-51-015Z.log.gz new file mode 100644 index 0000000..fa12dc1 Binary files /dev/null and b/collector-data/web-app_2026-05-04T10-10-51-015Z.log.gz differ diff --git a/collector-data/web-app_2026-05-04T10-20-51-163Z.log.gz b/collector-data/web-app_2026-05-04T10-20-51-163Z.log.gz new file mode 100644 index 0000000..fac0ebc Binary files /dev/null and b/collector-data/web-app_2026-05-04T10-20-51-163Z.log.gz differ diff --git a/collector-data/web-app_2026-05-04T10-30-51-304Z.log.gz b/collector-data/web-app_2026-05-04T10-30-51-304Z.log.gz new file mode 100644 index 0000000..4d05b37 Binary files /dev/null and b/collector-data/web-app_2026-05-04T10-30-51-304Z.log.gz differ diff --git a/collector-data/web-app_2026-05-04T10-40-51-472Z.log.gz b/collector-data/web-app_2026-05-04T10-40-51-472Z.log.gz new file mode 100644 index 0000000..bb18071 Binary files /dev/null and b/collector-data/web-app_2026-05-04T10-40-51-472Z.log.gz differ diff --git a/collector-data/web-app_2026-05-04T10-50-51-626Z.log.gz b/collector-data/web-app_2026-05-04T10-50-51-626Z.log.gz new file mode 100644 index 0000000..e1b15fa Binary files /dev/null and b/collector-data/web-app_2026-05-04T10-50-51-626Z.log.gz differ diff --git a/collector-data/web-app_2026-05-04T11-00-51-764Z.log.gz b/collector-data/web-app_2026-05-04T11-00-51-764Z.log.gz new file mode 100644 index 0000000..40688a0 Binary files /dev/null and b/collector-data/web-app_2026-05-04T11-00-51-764Z.log.gz differ diff --git a/collector-data/web-app_2026-05-04T11-10-51-938Z.log.gz b/collector-data/web-app_2026-05-04T11-10-51-938Z.log.gz new file mode 100644 index 0000000..8b2c0c9 Binary files /dev/null and b/collector-data/web-app_2026-05-04T11-10-51-938Z.log.gz differ diff --git a/collector-data/web-app_2026-05-04T11-20-52-093Z.log.gz b/collector-data/web-app_2026-05-04T11-20-52-093Z.log.gz new file mode 100644 index 0000000..12394d7 Binary files /dev/null and b/collector-data/web-app_2026-05-04T11-20-52-093Z.log.gz differ diff --git a/collector-data/web-app_2026-05-04T11-30-52-250Z.log.gz b/collector-data/web-app_2026-05-04T11-30-52-250Z.log.gz new file mode 100644 index 0000000..6d4e338 Binary files /dev/null and b/collector-data/web-app_2026-05-04T11-30-52-250Z.log.gz differ diff --git a/collector-data/web-app_2026-05-04T11-40-52-391Z.log.gz b/collector-data/web-app_2026-05-04T11-40-52-391Z.log.gz new file mode 100644 index 0000000..c1b1ccf Binary files /dev/null and b/collector-data/web-app_2026-05-04T11-40-52-391Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T14-43-01-879Z.log.gz b/collector-data/worker-1_2026-05-02T14-43-01-879Z.log.gz new file mode 100644 index 0000000..ac308b9 Binary files /dev/null and b/collector-data/worker-1_2026-05-02T14-43-01-879Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T14-53-02-037Z.log.gz b/collector-data/worker-1_2026-05-02T14-53-02-037Z.log.gz new file mode 100644 index 0000000..2d7c724 Binary files /dev/null and b/collector-data/worker-1_2026-05-02T14-53-02-037Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T15-03-02-179Z.log.gz b/collector-data/worker-1_2026-05-02T15-03-02-179Z.log.gz new file mode 100644 index 0000000..554c6ca Binary files /dev/null and b/collector-data/worker-1_2026-05-02T15-03-02-179Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T15-13-02-357Z.log.gz b/collector-data/worker-1_2026-05-02T15-13-02-357Z.log.gz new file mode 100644 index 0000000..6ed4041 Binary files /dev/null and b/collector-data/worker-1_2026-05-02T15-13-02-357Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T15-23-02-527Z.log.gz b/collector-data/worker-1_2026-05-02T15-23-02-527Z.log.gz new file mode 100644 index 0000000..caebc9b Binary files /dev/null and b/collector-data/worker-1_2026-05-02T15-23-02-527Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T15-33-02-730Z.log.gz b/collector-data/worker-1_2026-05-02T15-33-02-730Z.log.gz new file mode 100644 index 0000000..b149e35 Binary files /dev/null and b/collector-data/worker-1_2026-05-02T15-33-02-730Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T15-43-02-885Z.log.gz b/collector-data/worker-1_2026-05-02T15-43-02-885Z.log.gz new file mode 100644 index 0000000..9c714a1 Binary files /dev/null and b/collector-data/worker-1_2026-05-02T15-43-02-885Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T15-53-03-025Z.log.gz b/collector-data/worker-1_2026-05-02T15-53-03-025Z.log.gz new file mode 100644 index 0000000..2d6832c Binary files /dev/null and b/collector-data/worker-1_2026-05-02T15-53-03-025Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T16-03-03-176Z.log.gz b/collector-data/worker-1_2026-05-02T16-03-03-176Z.log.gz new file mode 100644 index 0000000..8d82f59 Binary files /dev/null and b/collector-data/worker-1_2026-05-02T16-03-03-176Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T16-13-03-327Z.log.gz b/collector-data/worker-1_2026-05-02T16-13-03-327Z.log.gz new file mode 100644 index 0000000..6e0fb49 Binary files /dev/null and b/collector-data/worker-1_2026-05-02T16-13-03-327Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T16-23-03-501Z.log.gz b/collector-data/worker-1_2026-05-02T16-23-03-501Z.log.gz new file mode 100644 index 0000000..201997f Binary files /dev/null and b/collector-data/worker-1_2026-05-02T16-23-03-501Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T16-33-03-674Z.log.gz b/collector-data/worker-1_2026-05-02T16-33-03-674Z.log.gz new file mode 100644 index 0000000..4ca8e31 Binary files /dev/null and b/collector-data/worker-1_2026-05-02T16-33-03-674Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T16-43-03-775Z.log.gz b/collector-data/worker-1_2026-05-02T16-43-03-775Z.log.gz new file mode 100644 index 0000000..2ae9fd9 Binary files /dev/null and b/collector-data/worker-1_2026-05-02T16-43-03-775Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T16-53-03-963Z.log.gz b/collector-data/worker-1_2026-05-02T16-53-03-963Z.log.gz new file mode 100644 index 0000000..4a45566 Binary files /dev/null and b/collector-data/worker-1_2026-05-02T16-53-03-963Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T17-03-04-101Z.log.gz b/collector-data/worker-1_2026-05-02T17-03-04-101Z.log.gz new file mode 100644 index 0000000..15bbe1b Binary files /dev/null and b/collector-data/worker-1_2026-05-02T17-03-04-101Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T17-13-04-227Z.log.gz b/collector-data/worker-1_2026-05-02T17-13-04-227Z.log.gz new file mode 100644 index 0000000..e6b4a48 Binary files /dev/null and b/collector-data/worker-1_2026-05-02T17-13-04-227Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T17-23-04-394Z.log.gz b/collector-data/worker-1_2026-05-02T17-23-04-394Z.log.gz new file mode 100644 index 0000000..af7154c Binary files /dev/null and b/collector-data/worker-1_2026-05-02T17-23-04-394Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T17-33-04-574Z.log.gz b/collector-data/worker-1_2026-05-02T17-33-04-574Z.log.gz new file mode 100644 index 0000000..7117978 Binary files /dev/null and b/collector-data/worker-1_2026-05-02T17-33-04-574Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T17-43-04-727Z.log.gz b/collector-data/worker-1_2026-05-02T17-43-04-727Z.log.gz new file mode 100644 index 0000000..7be60b2 Binary files /dev/null and b/collector-data/worker-1_2026-05-02T17-43-04-727Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T17-53-04-878Z.log.gz b/collector-data/worker-1_2026-05-02T17-53-04-878Z.log.gz new file mode 100644 index 0000000..8df13f3 Binary files /dev/null and b/collector-data/worker-1_2026-05-02T17-53-04-878Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T18-03-04-990Z.log.gz b/collector-data/worker-1_2026-05-02T18-03-04-990Z.log.gz new file mode 100644 index 0000000..c373543 Binary files /dev/null and b/collector-data/worker-1_2026-05-02T18-03-04-990Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T18-13-05-088Z.log.gz b/collector-data/worker-1_2026-05-02T18-13-05-088Z.log.gz new file mode 100644 index 0000000..f504269 Binary files /dev/null and b/collector-data/worker-1_2026-05-02T18-13-05-088Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T18-23-05-268Z.log.gz b/collector-data/worker-1_2026-05-02T18-23-05-268Z.log.gz new file mode 100644 index 0000000..c3cd845 Binary files /dev/null and b/collector-data/worker-1_2026-05-02T18-23-05-268Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T18-33-05-424Z.log.gz b/collector-data/worker-1_2026-05-02T18-33-05-424Z.log.gz new file mode 100644 index 0000000..23e2e1f Binary files /dev/null and b/collector-data/worker-1_2026-05-02T18-33-05-424Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T18-43-05-550Z.log.gz b/collector-data/worker-1_2026-05-02T18-43-05-550Z.log.gz new file mode 100644 index 0000000..592cf85 Binary files /dev/null and b/collector-data/worker-1_2026-05-02T18-43-05-550Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T18-53-05-699Z.log.gz b/collector-data/worker-1_2026-05-02T18-53-05-699Z.log.gz new file mode 100644 index 0000000..be84348 Binary files /dev/null and b/collector-data/worker-1_2026-05-02T18-53-05-699Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T19-03-05-852Z.log.gz b/collector-data/worker-1_2026-05-02T19-03-05-852Z.log.gz new file mode 100644 index 0000000..ba99386 Binary files /dev/null and b/collector-data/worker-1_2026-05-02T19-03-05-852Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T19-13-06-028Z.log.gz b/collector-data/worker-1_2026-05-02T19-13-06-028Z.log.gz new file mode 100644 index 0000000..674a920 Binary files /dev/null and b/collector-data/worker-1_2026-05-02T19-13-06-028Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T19-23-06-173Z.log.gz b/collector-data/worker-1_2026-05-02T19-23-06-173Z.log.gz new file mode 100644 index 0000000..9c1a63d Binary files /dev/null and b/collector-data/worker-1_2026-05-02T19-23-06-173Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T19-33-06-325Z.log.gz b/collector-data/worker-1_2026-05-02T19-33-06-325Z.log.gz new file mode 100644 index 0000000..2bf5fba Binary files /dev/null and b/collector-data/worker-1_2026-05-02T19-33-06-325Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T19-43-06-484Z.log.gz b/collector-data/worker-1_2026-05-02T19-43-06-484Z.log.gz new file mode 100644 index 0000000..2f74d0b Binary files /dev/null and b/collector-data/worker-1_2026-05-02T19-43-06-484Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T19-53-06-642Z.log.gz b/collector-data/worker-1_2026-05-02T19-53-06-642Z.log.gz new file mode 100644 index 0000000..ee0c902 Binary files /dev/null and b/collector-data/worker-1_2026-05-02T19-53-06-642Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T20-03-06-779Z.log.gz b/collector-data/worker-1_2026-05-02T20-03-06-779Z.log.gz new file mode 100644 index 0000000..1d4f249 Binary files /dev/null and b/collector-data/worker-1_2026-05-02T20-03-06-779Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T20-13-06-963Z.log.gz b/collector-data/worker-1_2026-05-02T20-13-06-963Z.log.gz new file mode 100644 index 0000000..0ba74e5 Binary files /dev/null and b/collector-data/worker-1_2026-05-02T20-13-06-963Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-02T20-23-07-108Z.log.gz b/collector-data/worker-1_2026-05-02T20-23-07-108Z.log.gz new file mode 100644 index 0000000..ddec54c Binary files /dev/null and b/collector-data/worker-1_2026-05-02T20-23-07-108Z.log.gz differ diff --git a/collector-data/worker-1_2026-05-04T08-50-49-891Z.log.gz b/collector-data/worker-1_2026-05-04T08-50-49-891Z.log.gz new file mode 100644 index 0000000..718dc28 Binary files /dev/null and b/collector-data/worker-1_2026-05-04T08-50-49-891Z.log.gz differ diff --git a/dist-server/collector.js b/dist-server/collector.js new file mode 100644 index 0000000..e71f033 --- /dev/null +++ b/dist-server/collector.js @@ -0,0 +1,497 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.LogCollector = void 0; +const dgram = __importStar(require("dgram")); +const fs = __importStar(require("fs")); +const path = __importStar(require("path")); +const zlib = __importStar(require("zlib")); +class LogCollector { + config; + streams = new Map(); + buffers = new Map(); + fileSizes = new Map(); + fileCreatedAt = new Map(); + rotationTimer = null; + udpServer = null; + forwardBuffers = new Map(); + forwardTimers = new Map(); + constructor(config = {}) { + this.config = { + storageDir: config.storageDir || path.join(process.cwd(), 'collector-data'), + maxLinesPerStream: config.maxLinesPerStream || 100000, + maxStreams: config.maxStreams || 50, + udpPort: config.udpPort || 5140, + udpEnabled: config.udpEnabled !== false, + rotation: config.rotation || { + maxSizeBytes: 5 * 1024 * 1024, // 5MB default + maxAgeMs: 10 * 60 * 1000, // 10 min default + compression: 'gzip', + fileExtension: '.log', + }, + forwardTargets: config.forwardTargets || [], + persistToDisk: config.persistToDisk !== false, + }; + if (!fs.existsSync(this.config.storageDir)) { + fs.mkdirSync(this.config.storageDir, { recursive: true }); + } + this.loadExistingStreams(); + // Start rotation check timer + if (this.config.rotation.maxAgeMs > 0) { + this.rotationTimer = setInterval(() => this.checkTimeRotation(), Math.min(this.config.rotation.maxAgeMs / 2, 30000)); + } + // Start UDP server + if (this.config.udpEnabled) { + this.startUdpServer(); + } + } + startUdpServer() { + try { + this.udpServer = dgram.createSocket('udp4'); + this.udpServer.on('message', (msg, rinfo) => { + const message = msg.toString('utf-8').trim(); + if (!message) + return; + // Parse stream ID from message or use sender IP as stream + // Format: | or just (uses IP:port as stream) + let streamId; + let logLine; + const pipeIdx = message.indexOf('|'); + if (pipeIdx > 0 && pipeIdx < 64 && !message.substring(0, pipeIdx).includes(' ')) { + streamId = message.substring(0, pipeIdx); + logLine = message.substring(pipeIdx + 1); + } + else { + streamId = `udp-${rinfo.address}`; + logLine = message; + } + this.ingest(streamId, [logLine], `UDP ${rinfo.address}:${rinfo.port}`); + }); + this.udpServer.on('error', (err) => { + console.error(`UDP server error: ${err.message}`); + }); + this.udpServer.bind(this.config.udpPort, '0.0.0.0', () => { + console.log(` UDP collector listening on port ${this.config.udpPort}`); + }); + } + catch (err) { + console.error('Failed to start UDP server:', err.message); + } + } + loadExistingStreams() { + try { + const metaFile = path.join(this.config.storageDir, '_streams.json'); + if (fs.existsSync(metaFile)) { + const data = JSON.parse(fs.readFileSync(metaFile, 'utf-8')); + for (const stream of data) { + this.streams.set(stream.id, stream); + const streamFile = this.getStreamFilePath(stream.id); + if (fs.existsSync(streamFile)) { + const content = fs.readFileSync(streamFile, 'utf-8'); + const lines = content.split('\n').filter(l => l.length > 0); + this.buffers.set(stream.id, lines); + const stat = fs.statSync(streamFile); + this.fileSizes.set(stream.id, stat.size); + this.fileCreatedAt.set(stream.id, stat.birthtimeMs || Date.now()); + } + else { + this.buffers.set(stream.id, []); + this.fileSizes.set(stream.id, 0); + this.fileCreatedAt.set(stream.id, Date.now()); + } + } + } + } + catch (err) { + console.error('Error loading collector streams:', err); + } + } + saveMetadata() { + const metaFile = path.join(this.config.storageDir, '_streams.json'); + fs.writeFileSync(metaFile, JSON.stringify(Array.from(this.streams.values()), null, 2)); + } + getStreamFilePath(streamId) { + return path.join(this.config.storageDir, `${streamId}${this.config.rotation.fileExtension}`); + } + getRotatedFilePath(streamId) { + const ts = new Date().toISOString().replace(/[:.]/g, '-'); + const ext = this.config.rotation.fileExtension; + const compExt = this.getCompressionExtension(); + return path.join(this.config.storageDir, `${streamId}_${ts}${ext}${compExt}`); + } + getCompressionExtension() { + switch (this.config.rotation.compression) { + case 'gzip': return '.gz'; + case 'bzip2': return '.bz2'; + case 'xz': return '.xz'; + case 'zstd': return '.zst'; + default: return ''; + } + } + saveStream(streamId) { + if (!this.config.persistToDisk) + return; + const lines = this.buffers.get(streamId) || []; + const streamFile = this.getStreamFilePath(streamId); + const content = lines.join('\n') + (lines.length > 0 ? '\n' : ''); + fs.writeFileSync(streamFile, content); + this.fileSizes.set(streamId, Buffer.byteLength(content, 'utf-8')); + } + checkSizeRotation(streamId) { + if (this.config.rotation.maxSizeBytes <= 0) + return; + const size = this.fileSizes.get(streamId) || 0; + if (size >= this.config.rotation.maxSizeBytes) { + this.rotateStream(streamId); + } + } + checkTimeRotation() { + if (this.config.rotation.maxAgeMs <= 0) + return; + const now = Date.now(); + for (const [streamId, createdAt] of this.fileCreatedAt) { + if (now - createdAt >= this.config.rotation.maxAgeMs) { + const buffer = this.buffers.get(streamId); + if (buffer && buffer.length > 0) { + this.rotateStream(streamId); + } + } + } + } + rotateStream(streamId) { + const srcFile = this.getStreamFilePath(streamId); + if (!fs.existsSync(srcFile)) + return; + const content = fs.readFileSync(srcFile); + if (content.length === 0) + return; + const destFile = this.getRotatedFilePath(streamId); + // Compress and write + let compressed; + switch (this.config.rotation.compression) { + case 'gzip': + compressed = zlib.gzipSync(content); + break; + case 'bzip2': + case 'xz': + case 'zstd': + // For these, just copy without compression (would need external tools) + compressed = content; + break; + default: + compressed = content; + } + fs.writeFileSync(destFile, compressed); + console.log(` Rotated: ${streamId} -> ${path.basename(destFile)} (${compressed.length} bytes)`); + // Reset current file + fs.writeFileSync(srcFile, ''); + this.buffers.set(streamId, []); + this.fileSizes.set(streamId, 0); + this.fileCreatedAt.set(streamId, Date.now()); + const stream = this.streams.get(streamId); + if (stream) { + stream.lineCount = 0; + stream.currentFileSize = 0; + } + this.saveMetadata(); + } + // ---- Forwarding ---- + forwardLines(streamId, lines) { + for (const target of this.config.forwardTargets) { + const key = `${streamId}:${target.type}:${target.url || target.path || target.host}`; + if (!this.forwardBuffers.has(key)) + this.forwardBuffers.set(key, []); + const buf = this.forwardBuffers.get(key); + buf.push(...lines); + const batchSize = target.batchSize || 100; + if (buf.length >= batchSize) { + this.flushForward(key, target, streamId); + } + else if (!this.forwardTimers.has(key)) { + const flushMs = target.flushIntervalMs || 5000; + this.forwardTimers.set(key, setTimeout(() => { + this.flushForward(key, target, streamId); + this.forwardTimers.delete(key); + }, flushMs)); + } + } + } + flushForward(key, target, streamId) { + const lines = this.forwardBuffers.get(key) || []; + if (lines.length === 0) + return; + this.forwardBuffers.set(key, []); + switch (target.type) { + case 'http': + this.forwardHttp(target, streamId, lines); + break; + case 'file': + this.forwardFile(target, lines); + break; + case 'udp': + this.forwardUdp(target, streamId, lines); + break; + case 'stream': + this.forwardToStream(target, streamId, lines); + break; + case 'console': + for (const l of lines) + console.log(`[FWD:${streamId}] ${l}`); + break; + } + } + forwardHttp(target, streamId, lines) { + if (!target.url) + return; + const payload = target.format === 'json' + ? JSON.stringify({ stream: streamId, lines, timestamp: new Date().toISOString() }) + : lines.join('\n'); + try { + const url = new URL(target.url); + const transport = url.protocol === 'https:' ? require('https') : require('http'); + const req = transport.request({ + hostname: url.hostname, + port: url.port || (url.protocol === 'https:' ? 443 : 80), + path: url.pathname, + method: 'POST', + headers: { 'Content-Type': target.format === 'json' ? 'application/json' : 'text/plain', 'X-Stream-Id': streamId }, + }); + req.on('error', (e) => console.error(`Forward HTTP error: ${e.message}`)); + req.write(payload); + req.end(); + } + catch (e) { + console.error(`Forward HTTP error: ${e.message}`); + } + } + forwardFile(target, lines) { + if (!target.path) + return; + try { + fs.appendFileSync(target.path, lines.join('\n') + '\n'); + } + catch (e) { + console.error(`Forward file error: ${e.message}`); + } + } + forwardUdp(target, streamId, lines) { + if (!target.host || !target.port) + return; + try { + const client = dgram.createSocket('udp4'); + for (const line of lines) { + const msg = Buffer.from(`${streamId}|${line}`); + client.send(msg, target.port, target.host); + } + setTimeout(() => client.close(), 1000); + } + catch (e) { + console.error(`Forward UDP error: ${e.message}`); + } + } + forwardToStream(target, sourceStreamId, lines) { + const targetId = target.targetStream; + if (!targetId || targetId === sourceStreamId) + return; // Prevent infinite loop + try { + // Prefix lines with source stream info + const prefixed = lines.map(l => `[fwd:${sourceStreamId}] ${l}`); + // Ingest directly into the target stream without triggering forwards again + const stream = this.getOrCreateStream(targetId, `Forwarded from ${sourceStreamId}`); + const buffer = this.buffers.get(targetId); + for (const line of prefixed) { + if (buffer.length >= this.config.maxLinesPerStream) + buffer.shift(); + buffer.push(line); + } + stream.lineCount = buffer.length; + stream.lastReceived = new Date().toISOString(); + if (this.config.persistToDisk) + this.saveStream(targetId); + this.saveMetadata(); + } + catch (e) { + console.error(`Forward stream error: ${e.message}`); + } + } + // ---- Public API ---- + getOrCreateStream(streamId, name) { + if (this.streams.has(streamId)) + return this.streams.get(streamId); + if (this.streams.size >= this.config.maxStreams) + throw new Error(`Max streams (${this.config.maxStreams}) reached`); + const stream = { + id: streamId, name: name || streamId, createdAt: new Date().toISOString(), + lineCount: 0, lastReceived: null, currentFileSize: 0, + }; + this.streams.set(streamId, stream); + this.buffers.set(streamId, []); + this.fileSizes.set(streamId, 0); + this.fileCreatedAt.set(streamId, Date.now()); + this.saveMetadata(); + return stream; + } + ingest(streamId, lines, streamName) { + const stream = this.getOrCreateStream(streamId, streamName); + const buffer = this.buffers.get(streamId); + let accepted = 0, dropped = 0; + const validLines = []; + for (const line of lines) { + if (line.trim() === '') + continue; + if (buffer.length >= this.config.maxLinesPerStream) { + buffer.shift(); + dropped++; + } + buffer.push(line); + validLines.push(line); + accepted++; + } + stream.lineCount = buffer.length; + stream.lastReceived = new Date().toISOString(); + if (this.config.persistToDisk) { + this.saveStream(streamId); + stream.currentFileSize = this.fileSizes.get(streamId) || 0; + this.checkSizeRotation(streamId); + } + // Forward to targets + if (validLines.length > 0 && this.config.forwardTargets.length > 0) { + this.forwardLines(streamId, validLines); + } + this.saveMetadata(); + return { accepted, dropped }; + } + getStreams() { return Array.from(this.streams.values()); } + readStream(streamId, tail) { + const buffer = this.buffers.get(streamId); + if (!buffer) + throw new Error(`Stream not found: ${streamId}`); + return tail && tail > 0 ? buffer.slice(-tail) : [...buffer]; + } + deleteStream(streamId) { + if (!this.streams.has(streamId)) + return false; + this.streams.delete(streamId); + this.buffers.delete(streamId); + this.fileSizes.delete(streamId); + this.fileCreatedAt.delete(streamId); + const f = this.getStreamFilePath(streamId); + if (fs.existsSync(f)) + fs.unlinkSync(f); + this.saveMetadata(); + return true; + } + getConfig() { return { ...this.config }; } + shutdown() { + if (this.rotationTimer) + clearInterval(this.rotationTimer); + if (this.udpServer) + this.udpServer.close(); + for (const timer of this.forwardTimers.values()) + clearTimeout(timer); + } + handleRequest(req, res, pathname) { + const method = req.method || 'GET'; + // POST /collect/:streamId + const collectMatch = pathname.match(/^\/collect\/([a-zA-Z0-9_\-\.]+)(\/json)?$/); + if (collectMatch && method === 'POST') { + const streamId = collectMatch[1]; + const isJson = !!collectMatch[2]; + let body = ''; + req.on('data', (chunk) => { body += chunk.toString(); }); + req.on('end', () => { + try { + let lines; + if (isJson) { + const parsed = JSON.parse(body); + lines = Array.isArray(parsed) ? parsed.map((e) => typeof e === 'string' ? e : JSON.stringify(e)) : [JSON.stringify(parsed)]; + } + else { + lines = body.split('\n'); + } + const streamName = req.headers['x-stream-name']; + const result = this.ingest(streamId, lines, streamName); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, ...result })); + } + catch (err) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: err.message })); + } + }); + return true; + } + // GET /collector/streams + if (pathname === '/collector/streams' && method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(this.getStreams())); + return true; + } + // GET /collector/config + if (pathname === '/collector/config' && method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(this.getConfig())); + return true; + } + // GET /collector/stream/:id + const readMatch = pathname.match(/^\/collector\/stream\/([a-zA-Z0-9_\-\.]+)$/); + if (readMatch && method === 'GET') { + const streamId = readMatch[1]; + try { + const url = new URL(req.url || '/', `http://${req.headers.host}`); + const tail = url.searchParams.get('tail'); + const lines = this.readStream(streamId, tail ? parseInt(tail, 10) : undefined); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ streamId, lineCount: lines.length, lines })); + } + catch (err) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: err.message })); + } + return true; + } + // DELETE /collector/stream/:id + const deleteMatch = pathname.match(/^\/collector\/stream\/([a-zA-Z0-9_\-\.]+)$/); + if (deleteMatch && method === 'DELETE') { + const deleted = this.deleteStream(deleteMatch[1]); + res.writeHead(deleted ? 200 : 404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: deleted })); + return true; + } + return false; + } +} +exports.LogCollector = LogCollector; +//# sourceMappingURL=collector.js.map \ No newline at end of file diff --git a/dist-server/collector.js.map b/dist-server/collector.js.map new file mode 100644 index 0000000..df56b73 --- /dev/null +++ b/dist-server/collector.js.map @@ -0,0 +1 @@ +{"version":3,"file":"collector.js","sourceRoot":"","sources":["../src/server/collector.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist-server/configFile.js b/dist-server/configFile.js new file mode 100644 index 0000000..0086e05 --- /dev/null +++ b/dist-server/configFile.js @@ -0,0 +1,101 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getConfigFilePath = getConfigFilePath; +exports.loadConfigFile = loadConfigFile; +exports.saveConfigFile = saveConfigFile; +const fs = __importStar(require("fs")); +const path = __importStar(require("path")); +const CONFIG_FILE = 'logviewer.config.json'; +function getConfigFilePath() { + return path.join(process.cwd(), CONFIG_FILE); +} +function loadConfigFile() { + const defaults = { + mode: 'both', + port: 8080, + host: '0.0.0.0', + viewer: { + sources: [], + theme: 'dark', + refreshInterval: 2000, + tailMode: true, + maxLines: 50000, + defaultLevel: 'ALL', + }, + collector: { + enabled: true, + storageDir: './collector-data', + maxLinesPerStream: 100000, + maxStreams: 50, + persistToDisk: true, + udp: { enabled: true, port: 5140 }, + rotation: { maxSizeBytes: 5242880, maxAgeMs: 600000, compression: 'gzip', fileExtension: '.log' }, + forwardTargets: [], + }, + s3: { sources: {} }, + remoteCollector: { url: '' }, + }; + const filePath = getConfigFilePath(); + if (fs.existsSync(filePath)) { + try { + const fileContent = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + return deepMerge(defaults, fileContent); + } + catch (err) { + console.error(`Error reading ${CONFIG_FILE}:`, err.message); + } + } + return defaults; +} +function saveConfigFile(config) { + const filePath = getConfigFilePath(); + const existing = loadConfigFile(); + const merged = deepMerge(existing, config); + fs.writeFileSync(filePath, JSON.stringify(merged, null, 2)); +} +function deepMerge(target, source) { + const result = { ...target }; + for (const key of Object.keys(source)) { + if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { + result[key] = deepMerge(target[key] || {}, source[key]); + } + else { + result[key] = source[key]; + } + } + return result; +} +//# sourceMappingURL=configFile.js.map \ No newline at end of file diff --git a/dist-server/configFile.js.map b/dist-server/configFile.js.map new file mode 100644 index 0000000..811fcb4 --- /dev/null +++ b/dist-server/configFile.js.map @@ -0,0 +1 @@ +{"version":3,"file":"configFile.js","sourceRoot":"","sources":["../src/server/configFile.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist-server/configManager.js b/dist-server/configManager.js new file mode 100644 index 0000000..4bc53ec --- /dev/null +++ b/dist-server/configManager.js @@ -0,0 +1,97 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ConfigManager = void 0; +const fs = __importStar(require("fs")); +const path = __importStar(require("path")); +class ConfigManager { + config; + constructor() { + this.config = this.loadConfig(); + } + loadConfig() { + this.loadEnvFile(); + const sourcesRaw = process.env.LOG_SOURCES || ''; + const sources = sourcesRaw + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + return { + sources, + refreshInterval: parseInt(process.env.LOG_REFRESH_INTERVAL || '2000', 10), + maxLines: parseInt(process.env.LOG_MAX_LINES || '50000', 10), + theme: process.env.LOG_THEME || 'dark', + defaultLevel: process.env.LOG_DEFAULT_LEVEL || 'ALL', + tailMode: process.env.LOG_TAIL_MODE !== 'false', + timestampRegex: process.env.LOG_TIMESTAMP_REGEX || null, + }; + } + loadEnvFile() { + const envPaths = [ + path.join(process.cwd(), '.env'), + path.join(__dirname, '..', '..', '.env'), + ]; + for (const envPath of envPaths) { + if (fs.existsSync(envPath)) { + const content = fs.readFileSync(envPath, 'utf-8'); + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith('#')) { + const eqIndex = trimmed.indexOf('='); + if (eqIndex > 0) { + const key = trimmed.substring(0, eqIndex).trim(); + const value = trimmed.substring(eqIndex + 1).trim(); + if (!process.env[key]) { + process.env[key] = value; + } + } + } + } + break; + } + } + } + getConfig() { + return { ...this.config }; + } + updateSources(sources) { + this.config.sources = sources; + } + getSources() { + return [...this.config.sources]; + } +} +exports.ConfigManager = ConfigManager; +//# sourceMappingURL=configManager.js.map \ No newline at end of file diff --git a/dist-server/configManager.js.map b/dist-server/configManager.js.map new file mode 100644 index 0000000..6377f95 --- /dev/null +++ b/dist-server/configManager.js.map @@ -0,0 +1 @@ +{"version":3,"file":"configManager.js","sourceRoot":"","sources":["../src/server/configManager.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist-server/logSourceManager.js b/dist-server/logSourceManager.js new file mode 100644 index 0000000..94caf9b --- /dev/null +++ b/dist-server/logSourceManager.js @@ -0,0 +1,280 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.LogSourceManager = void 0; +const fs = __importStar(require("fs")); +const path = __importStar(require("path")); +const zlib = __importStar(require("zlib")); +const child_process_1 = require("child_process"); +class LogSourceManager { + configManager; + sources = new Map(); + constructor(configManager) { + this.configManager = configManager; + this.initializeSources(); + } + initializeSources() { + const configSources = this.configManager.getSources(); + for (const source of configSources) { + this.addSource(source); + } + } + addSource(sourcePath) { + if (sourcePath.includes('*')) { + const resolvedPaths = this.resolveGlob(sourcePath); + for (const rp of resolvedPaths) { + this.addSingleSource(rp); + } + } + else { + this.addSingleSource(sourcePath); + } + this.configManager.updateSources(Array.from(this.sources.keys())); + } + addSingleSource(sourcePath) { + const resolved = path.resolve(sourcePath); + const type = this.detectType(resolved); + let size = 0; + let lastModified = null; + let exists = false; + try { + const stats = fs.statSync(resolved); + size = stats.size; + lastModified = stats.mtime.toISOString(); + exists = true; + } + catch { + // File doesn't exist yet + } + this.sources.set(resolved, { + path: resolved, + displayName: path.basename(resolved), + type, + size, + lastModified, + exists, + }); + } + removeSource(sourcePath) { + const resolved = path.resolve(sourcePath); + this.sources.delete(resolved); + this.configManager.updateSources(Array.from(this.sources.keys())); + } + getSources() { + for (const [key, source] of this.sources) { + try { + const stats = fs.statSync(key); + source.size = stats.size; + source.lastModified = stats.mtime.toISOString(); + source.exists = true; + } + catch { + source.exists = false; + } + } + return Array.from(this.sources.values()); + } + detectType(filePath) { + const ext = path.extname(filePath).toLowerCase(); + switch (ext) { + case '.gz': + case '.gzip': return 'gzip'; + case '.bz2': + case '.bzip2': return 'bzip2'; + case '.xz': return 'xz'; + case '.zst': + case '.zstd': return 'zstd'; + default: return 'text'; + } + } + async readLog(sourcePath) { + const resolved = path.resolve(sourcePath); + const source = this.sources.get(resolved); + if (!source) + throw new Error(`Source not found: ${sourcePath}`); + const config = this.configManager.getConfig(); + const content = this.readFileContent(resolved, source.type); + const lines = content.split('\n'); + const maxLines = config.maxLines; + const startIndex = lines.length > maxLines ? lines.length - maxLines : 0; + const entries = []; + for (let i = startIndex; i < lines.length; i++) { + const line = lines[i]; + if (line.trim() === '') + continue; + entries.push({ + line, + lineNumber: i + 1, + source: resolved, + timestamp: this.extractTimestamp(line, config.timestampRegex), + level: this.extractLevel(line), + }); + } + return entries; + } + async readAllLogs() { + const allEntries = []; + for (const [sourcePath] of this.sources) { + try { + const entries = await this.readLog(sourcePath); + allEntries.push(...entries); + } + catch (err) { + allEntries.push({ + line: `[ERROR] Could not read ${sourcePath}: ${err.message}`, + lineNumber: 0, + source: sourcePath, + timestamp: new Date().toISOString(), + level: 'ERROR', + }); + } + } + allEntries.sort((a, b) => { + if (a.timestamp && b.timestamp) + return a.timestamp.localeCompare(b.timestamp); + return 0; + }); + return allEntries; + } + readFileContent(filePath, type) { + switch (type) { + case 'gzip': return this.readGzip(filePath); + case 'bzip2': return this.readBzip2(filePath); + case 'xz': return this.readXz(filePath); + case 'zstd': return this.readZstd(filePath); + default: return fs.readFileSync(filePath, 'utf-8'); + } + } + readGzip(filePath) { + const compressed = fs.readFileSync(filePath); + return zlib.gunzipSync(compressed).toString('utf-8'); + } + readBzip2(filePath) { + try { + return (0, child_process_1.execSync)(`bzcat "${filePath}"`, { maxBuffer: 100 * 1024 * 1024 }).toString('utf-8'); + } + catch { + throw new Error('Cannot decompress bzip2. Make sure bzip2 is installed.'); + } + } + readXz(filePath) { + try { + return (0, child_process_1.execSync)(`xzcat "${filePath}"`, { maxBuffer: 100 * 1024 * 1024 }).toString('utf-8'); + } + catch { + throw new Error('Cannot decompress xz. Make sure xz-utils is installed.'); + } + } + readZstd(filePath) { + try { + return (0, child_process_1.execSync)(`zstdcat "${filePath}"`, { maxBuffer: 100 * 1024 * 1024 }).toString('utf-8'); + } + catch { + throw new Error('Cannot decompress zstd. Make sure zstd is installed.'); + } + } + extractTimestamp(line, customRegex) { + const patterns = [ + /(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)/, + /^([A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})/, + /\[(\d{2}\/[A-Z][a-z]{2}\/\d{4}:\d{2}:\d{2}:\d{2}\s[+-]\d{4})\]/, + /(\d{4}\/\d{2}\/\d{2}\s+\d{2}:\d{2}:\d{2})/, + ]; + if (customRegex) { + try { + const match = line.match(new RegExp(customRegex)); + if (match) + return match[1] || match[0]; + } + catch { /* skip */ } + } + for (const pattern of patterns) { + const match = line.match(pattern); + if (match) + return match[1]; + } + return null; + } + extractLevel(line) { + const upper = line.toUpperCase(); + const levels = ['FATAL', 'ERROR', 'WARN', 'WARNING', 'INFO', 'DEBUG', 'TRACE', 'VERBOSE']; + for (const level of levels) { + if (new RegExp(`\\b${level}\\b`).test(upper)) { + return level === 'WARNING' ? 'WARN' : level; + } + } + return null; + } + resolveGlob(pattern) { + const dir = path.dirname(pattern); + const filePattern = path.basename(pattern); + const results = []; + try { + const files = fs.readdirSync(dir); + const regex = new RegExp('^' + filePattern.replace(/\./g, '\\.').replace(/\*/g, '.*').replace(/\?/g, '.') + '$'); + for (const file of files) { + if (regex.test(file)) { + const fullPath = path.join(dir, file); + try { + if (fs.statSync(fullPath).isFile()) + results.push(fullPath); + } + catch { /* skip */ } + } + } + } + catch { /* dir doesn't exist */ } + return results; + } + async getFileStats(sourcePath) { + const resolved = path.resolve(sourcePath); + try { + const stats = fs.statSync(resolved); + const source = this.sources.get(resolved); + const content = this.readFileContent(resolved, source?.type || 'text'); + return { + size: stats.size, + lastModified: stats.mtime.toISOString(), + lineCount: content.split('\n').length, + type: source?.type || 'text', + }; + } + catch { + return null; + } + } +} +exports.LogSourceManager = LogSourceManager; +//# sourceMappingURL=logSourceManager.js.map \ No newline at end of file diff --git a/dist-server/logSourceManager.js.map b/dist-server/logSourceManager.js.map new file mode 100644 index 0000000..dd6aed4 --- /dev/null +++ b/dist-server/logSourceManager.js.map @@ -0,0 +1 @@ +{"version":3,"file":"logSourceManager.js","sourceRoot":"","sources":["../src/server/logSourceManager.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist-server/monitor.js b/dist-server/monitor.js new file mode 100644 index 0000000..8c51fe0 --- /dev/null +++ b/dist-server/monitor.js @@ -0,0 +1,516 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.LogMonitor = void 0; +const fs = __importStar(require("fs")); +const path = __importStar(require("path")); +const zlib = __importStar(require("zlib")); +const http = __importStar(require("http")); +const https = __importStar(require("https")); +// ---- Monitor Engine ---- +class LogMonitor { + config; + scanTimer = null; + results = []; + constructor(config = {}) { + this.config = { + enabled: config.enabled !== false, + scanIntervalMs: config.scanIntervalMs || 60000, + rules: config.rules || [], + storageDir: config.storageDir || path.join(process.cwd(), 'monitor-data'), + }; + if (!fs.existsSync(this.config.storageDir)) { + fs.mkdirSync(this.config.storageDir, { recursive: true }); + } + this.loadState(); + if (this.config.enabled && this.config.rules.length > 0) { + this.startScanning(); + } + } + loadState() { + const stateFile = path.join(this.config.storageDir, '_monitor_state.json'); + if (fs.existsSync(stateFile)) { + try { + const data = JSON.parse(fs.readFileSync(stateFile, 'utf-8')); + this.results = data.results || []; + // Restore lastTriggered from state + if (data.rules) { + for (const saved of data.rules) { + const rule = this.config.rules.find(r => r.id === saved.id); + if (rule) { + rule.lastTriggered = saved.lastTriggered; + rule.matchCount = saved.matchCount || 0; + } + } + } + } + catch { } + } + } + saveState() { + const stateFile = path.join(this.config.storageDir, '_monitor_state.json'); + fs.writeFileSync(stateFile, JSON.stringify({ + results: this.results.slice(-100), + rules: this.config.rules.map(r => ({ id: r.id, lastTriggered: r.lastTriggered, matchCount: r.matchCount })), + }, null, 2)); + } + startScanning() { + if (this.scanTimer) + clearInterval(this.scanTimer); + console.log(` Monitor scanning every ${this.config.scanIntervalMs / 1000}s with ${this.config.rules.length} rule(s)`); + this.scanTimer = setInterval(() => this.runScan(), this.config.scanIntervalMs); + // Run immediately + setTimeout(() => this.runScan(), 2000); + } + stopScanning() { + if (this.scanTimer) { + clearInterval(this.scanTimer); + this.scanTimer = null; + } + } + async runScan() { + for (const rule of this.config.rules) { + if (!rule.enabled) + continue; + // Check schedule + if (rule.schedule && rule.schedule.mode !== 'interval') { + if (!this.shouldRunBySchedule(rule)) + continue; + } + // Check cooldown + if (rule.lastTriggered) { + const elapsed = Date.now() - new Date(rule.lastTriggered).getTime(); + if (elapsed < rule.cooldownMs) + continue; + } + try { + const matches = this.scanRule(rule); + if (matches.length > 0) { + const result = { + ruleId: rule.id, + ruleName: rule.name, + matchedLines: matches, + totalMatches: matches.length, + sources: rule.sources, + timestamp: new Date().toISOString(), + }; + rule.lastTriggered = result.timestamp; + rule.matchCount += matches.length; + this.results.push(result); + // Send notifications + await this.notify(rule, result); + this.saveState(); + } + } + catch (err) { + console.error(` Monitor rule "${rule.name}" error:`, err.message); + } + } + } + shouldRunBySchedule(rule) { + const schedule = rule.schedule; + if (!schedule) + return true; + const now = new Date(); + const lastRun = rule.lastTriggered ? new Date(rule.lastTriggered) : null; + switch (schedule.mode) { + case 'daily': { + // Run once per day at specified time (HH:MM) + const [h, m] = (schedule.value || '07:00').split(':').map(Number); + const targetToday = new Date(now); + targetToday.setHours(h, m, 0, 0); + // Already ran today? + if (lastRun && lastRun.toDateString() === now.toDateString()) + return false; + // Is it past the target time? + return now >= targetToday; + } + case 'hourly': { + // Run every N hours + const hours = parseInt(schedule.value || '2', 10); + if (!lastRun) + return true; + return (now.getTime() - lastRun.getTime()) >= hours * 3600000; + } + case 'cron': { + // Simple cron: just check if enough time passed based on scan interval + // Full cron parsing would need a library, so we approximate + return true; + } + default: return true; + } + } + scanRule(rule) { + const allMatches = []; + const regexes = rule.patterns.map(p => new RegExp(p, 'i')); + for (const source of rule.sources) { + const files = this.resolveFiles(source); + for (const file of files) { + if (!this.isInTimeRange(file, rule.timeRange)) + continue; + try { + const content = this.readFile(file); + const lines = content.split('\n'); + for (const line of lines) { + if (line.trim() === '') + continue; + const matched = rule.patternLogic === 'all' + ? regexes.every(r => r.test(line)) + : regexes.some(r => r.test(line)); + if (matched) + allMatches.push(line); + } + } + catch { } + } + } + return allMatches; + } + resolveFiles(pattern) { + if (pattern.includes('*')) { + const dir = path.dirname(pattern); + const filePattern = path.basename(pattern); + try { + const files = fs.readdirSync(dir); + const regex = new RegExp('^' + filePattern.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$'); + return files.filter(f => regex.test(f)).map(f => path.join(dir, f)); + } + catch { + return []; + } + } + return fs.existsSync(pattern) ? [pattern] : []; + } + isInTimeRange(file, range) { + if (range.type === 'all') + return true; + try { + const stat = fs.statSync(file); + const fileTime = stat.mtime.getTime(); + const now = Date.now(); + switch (range.type) { + case 'today': { + const startOfDay = new Date(); + startOfDay.setHours(0, 0, 0, 0); + return fileTime >= startOfDay.getTime(); + } + case 'week': return fileTime >= now - 7 * 24 * 60 * 60 * 1000; + case 'month': return fileTime >= now - 30 * 24 * 60 * 60 * 1000; + case 'hours': return fileTime >= now - (range.hours || 24) * 60 * 60 * 1000; + default: return true; + } + } + catch { + return false; + } + } + readFile(filePath) { + const ext = path.extname(filePath).toLowerCase(); + if (ext === '.gz' || ext === '.gzip') { + return zlib.gunzipSync(fs.readFileSync(filePath)).toString('utf-8'); + } + return fs.readFileSync(filePath, 'utf-8'); + } + // ---- Notifications ---- + async notify(rule, result) { + const summary = this.buildSummary(rule, result); + const attachment = result.matchedLines.length > rule.maxLinesInMessage + ? this.buildAttachment(result) + : null; + for (const channel of rule.notifications) { + try { + switch (channel.type) { + case 'webhook': + await this.sendWebhook(channel, summary, attachment); + break; + case 'telegram': + await this.sendTelegram(channel, summary, attachment); + break; + case 'teams': + await this.sendTeams(channel, summary); + break; + case 'email': + console.log(` [Monitor] Email notification not yet implemented`); + break; + } + } + catch (err) { + console.error(` [Monitor] Notification error (${channel.type}):`, err.message); + } + } + } + buildSummary(rule, result) { + const lines = result.matchedLines.slice(0, rule.maxLinesInMessage); + let msg = `🚨 **Log Monitor Alert: ${rule.name}**\n\n`; + msg += `**Matches:** ${result.totalMatches} lines\n`; + msg += `**Sources:** ${result.sources.join(', ')}\n`; + msg += `**Time:** ${result.timestamp}\n`; + msg += `**Patterns:** ${rule.patterns.join(' | ')}\n\n`; + msg += `**Sample (first ${lines.length} of ${result.totalMatches}):**\n`; + msg += '```\n' + lines.join('\n') + '\n```'; + if (result.totalMatches > rule.maxLinesInMessage) { + msg += `\n\n📎 Full results (${result.totalMatches} lines) attached as .gz file.`; + } + return msg; + } + buildAttachment(result) { + const content = result.matchedLines.join('\n'); + return zlib.gzipSync(Buffer.from(content, 'utf-8')); + } + async sendWebhook(channel, message, attachment) { + if (!channel.url) + return; + const payload = JSON.stringify({ + text: message, + matches: attachment ? `(${attachment.length} bytes gzipped attachment)` : undefined, + }); + await this.httpPost(channel.url, payload, 'application/json'); + console.log(` [Monitor] Webhook sent to ${channel.url}`); + } + async sendTelegram(channel, message, attachment) { + if (!channel.botToken || !channel.chatId) + return; + // Send text message - use plain text to avoid Markdown parsing issues + const payload = JSON.stringify({ + chat_id: channel.chatId, + text: message.replace(/[*_`\[]/g, '').substring(0, 4000), // Strip markdown, respect limit + }); + await this.httpPost(`https://api.telegram.org/bot${channel.botToken}/sendMessage`, payload, 'application/json'); + console.log(` [Monitor] Telegram sent to chat ${channel.chatId}`); + } + async sendTeams(channel, message) { + if (!channel.url) + return; + const payload = JSON.stringify({ + '@type': 'MessageCard', + summary: 'Log Monitor Alert', + text: message.replace(/\n/g, '
').replace(/```/g, ''), + }); + await this.httpPost(channel.url, payload, 'application/json'); + console.log(` [Monitor] Teams webhook sent`); + } + httpPost(targetUrl, body, contentType) { + return new Promise((resolve, reject) => { + const parsed = new URL(targetUrl); + const isHttps = parsed.protocol === 'https:'; + const transport = isHttps ? https : http; + const req = transport.request({ + hostname: parsed.hostname, + port: parsed.port || (isHttps ? 443 : 80), + path: parsed.pathname + parsed.search, + method: 'POST', + headers: { 'Content-Type': contentType, 'Content-Length': Buffer.byteLength(body) }, + }, (res) => { + let responseBody = ''; + res.on('data', (c) => { responseBody += c; }); + res.on('end', () => { + if (res.statusCode && res.statusCode >= 400) { + reject(new Error(`HTTP ${res.statusCode}: ${responseBody.substring(0, 200)}`)); + } + else { + resolve(); + } + }); + }); + req.on('error', reject); + req.setTimeout(10000, () => { req.destroy(); reject(new Error('Timeout')); }); + req.write(body); + req.end(); + }); + } + // ---- Public API ---- + getRules() { return this.config.rules; } + getResults() { return this.results; } + getConfig() { return { ...this.config }; } + addRule(rule) { + this.config.rules.push(rule); + this.saveState(); + if (this.config.rules.length === 1 && !this.scanTimer) + this.startScanning(); + } + updateRule(id, updates) { + const rule = this.config.rules.find(r => r.id === id); + if (!rule) + return false; + Object.assign(rule, updates); + this.saveState(); + return true; + } + deleteRule(id) { + const idx = this.config.rules.findIndex(r => r.id === id); + if (idx === -1) + return false; + this.config.rules.splice(idx, 1); + this.saveState(); + return true; + } + triggerRule(id) { + const rule = this.config.rules.find(r => r.id === id); + if (!rule) + return null; + const matches = this.scanRule(rule); + if (matches.length === 0) + return null; + const result = { + ruleId: rule.id, ruleName: rule.name, matchedLines: matches, + totalMatches: matches.length, sources: rule.sources, timestamp: new Date().toISOString(), + }; + this.results.push(result); + rule.matchCount += matches.length; + this.saveState(); + return result; + } + getAttachment(ruleId) { + const result = [...this.results].reverse().find(r => r.ruleId === ruleId); + if (!result) + return null; + return zlib.gzipSync(Buffer.from(result.matchedLines.join('\n'), 'utf-8')); + } + handleRequest(req, res, pathname) { + const method = req.method || 'GET'; + if (pathname === '/api/monitor/rules' && method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(this.getRules())); + return true; + } + if (pathname === '/api/monitor/rules' && method === 'POST') { + let body = ''; + req.on('data', c => body += c); + req.on('end', () => { + try { + const rule = JSON.parse(body); + if (!rule.id) + rule.id = 'rule-' + Date.now(); + if (!rule.maxLinesInMessage) + rule.maxLinesInMessage = 20; + if (!rule.cooldownMs) + rule.cooldownMs = 300000; + if (!rule.matchCount) + rule.matchCount = 0; + if (!rule.lastTriggered) + rule.lastTriggered = null; + this.addRule(rule); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, rule })); + } + catch (e) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: e.message })); + } + }); + return true; + } + // Test notification endpoint + if (pathname === '/api/monitor/test-notification' && method === 'POST') { + let body = ''; + req.on('data', c => body += c); + req.on('end', async () => { + try { + const channel = JSON.parse(body); + const testMsg = `🧪 **Test Notification**\n\nThis is a test from LogViewer Monitor.\nTimestamp: ${new Date().toISOString()}\n\nIf you see this, notifications are working! ✅`; + await this.sendTestNotification(channel, testMsg); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + } + catch (e) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: e.message })); + } + }); + return true; + } + const ruleMatch = pathname.match(/^\/api\/monitor\/rule\/([a-zA-Z0-9_\-]+)$/); + if (ruleMatch && method === 'DELETE') { + const ok = this.deleteRule(ruleMatch[1]); + res.writeHead(ok ? 200 : 404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok })); + return true; + } + if (ruleMatch && method === 'PUT') { + let body = ''; + req.on('data', c => body += c); + req.on('end', () => { + const updates = JSON.parse(body); + const ok = this.updateRule(ruleMatch[1], updates); + res.writeHead(ok ? 200 : 404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok })); + }); + return true; + } + const triggerMatch = pathname.match(/^\/api\/monitor\/trigger\/([a-zA-Z0-9_\-]+)$/); + if (triggerMatch && method === 'POST') { + const result = this.triggerRule(triggerMatch[1]); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result || { matches: 0 })); + return true; + } + if (pathname === '/api/monitor/results' && method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(this.results.slice(-50))); + return true; + } + if (pathname === '/api/monitor/config' && method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(this.getConfig())); + return true; + } + return false; + } + async sendTestNotification(channel, message) { + switch (channel.type) { + case 'webhook': + if (!channel.url) + throw new Error('No webhook URL provided'); + await this.sendWebhook(channel, message, null); + break; + case 'telegram': + if (!channel.botToken || !channel.chatId) + throw new Error('Bot token and chat ID required'); + await this.sendTelegram(channel, message, null); + break; + case 'teams': + if (!channel.url) + throw new Error('No Teams webhook URL provided'); + await this.sendTeams(channel, message); + break; + case 'email': + throw new Error('Email test not yet implemented'); + default: + throw new Error(`Unknown channel type: ${channel.type}`); + } + } +} +exports.LogMonitor = LogMonitor; +//# sourceMappingURL=monitor.js.map \ No newline at end of file diff --git a/dist-server/monitor.js.map b/dist-server/monitor.js.map new file mode 100644 index 0000000..0b6b409 --- /dev/null +++ b/dist-server/monitor.js.map @@ -0,0 +1 @@ +{"version":3,"file":"monitor.js","sourceRoot":"","sources":["../src/server/monitor.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist-server/s3Source.js b/dist-server/s3Source.js new file mode 100644 index 0000000..9fe2311 --- /dev/null +++ b/dist-server/s3Source.js @@ -0,0 +1,192 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.S3Source = void 0; +const https = __importStar(require("https")); +const http = __importStar(require("http")); +const crypto = __importStar(require("crypto")); +const zlib = __importStar(require("zlib")); +/** + * Minimal S3 client without external dependencies. + * Supports AWS S3 and S3-compatible services (MinIO, DigitalOcean Spaces, etc.) + */ +class S3Source { + config; + host; + useHttps; + constructor(config) { + this.config = config; + if (config.endpoint) { + const url = new URL(config.endpoint); + this.host = url.host; + this.useHttps = url.protocol === 'https:'; + } + else { + this.host = `${config.bucket}.s3.${config.region}.amazonaws.com`; + this.useHttps = true; + } + } + /** + * List objects in the bucket with optional prefix + */ + async listObjects(prefix) { + const usedPrefix = prefix || this.config.prefix || ''; + const queryParams = usedPrefix ? `list-type=2&prefix=${encodeURIComponent(usedPrefix)}` : 'list-type=2'; + const path = this.config.endpoint ? `/${this.config.bucket}/?${queryParams}` : `/?${queryParams}`; + const response = await this.request('GET', path, ''); + const objects = []; + // Simple XML parsing for S3 ListObjectsV2 response + const contentRegex = /([\s\S]*?)<\/Contents>/g; + let match; + while ((match = contentRegex.exec(response)) !== null) { + const content = match[1]; + const key = this.extractXmlValue(content, 'Key'); + const size = parseInt(this.extractXmlValue(content, 'Size') || '0', 10); + const lastModified = this.extractXmlValue(content, 'LastModified') || ''; + if (key && !key.endsWith('/')) { + objects.push({ key, size, lastModified }); + } + } + return objects; + } + /** + * Download an object's content as string + */ + async getObject(key) { + const path = this.config.endpoint + ? `/${this.config.bucket}/${encodeURIComponent(key)}` + : `/${encodeURIComponent(key)}`; + const response = await this.requestRaw('GET', path, ''); + // Auto-decompress if gzipped + if (key.endsWith('.gz') || key.endsWith('.gzip')) { + try { + const decompressed = zlib.gunzipSync(response); + return decompressed.toString('utf-8'); + } + catch { + return response.toString('utf-8'); + } + } + return response.toString('utf-8'); + } + extractXmlValue(xml, tag) { + const regex = new RegExp(`<${tag}>(.*?)`); + const match = xml.match(regex); + return match ? match[1] : null; + } + async request(method, path, body) { + const buf = await this.requestRaw(method, path, body); + return buf.toString('utf-8'); + } + async requestRaw(method, path, body) { + const now = new Date(); + const dateStamp = now.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; + const shortDate = dateStamp.substring(0, 8); + const headers = { + 'host': this.host, + 'x-amz-date': dateStamp, + 'x-amz-content-sha256': this.sha256(body), + }; + // Create canonical request + const [pathPart, queryPart] = path.split('?'); + const canonicalQueryString = queryPart || ''; + const signedHeaderKeys = Object.keys(headers).sort(); + const signedHeaders = signedHeaderKeys.join(';'); + const canonicalHeaders = signedHeaderKeys.map(k => `${k}:${headers[k]}\n`).join(''); + const canonicalRequest = [ + method, + pathPart, + canonicalQueryString, + canonicalHeaders, + signedHeaders, + headers['x-amz-content-sha256'], + ].join('\n'); + // Create string to sign + const credentialScope = `${shortDate}/${this.config.region}/s3/aws4_request`; + const stringToSign = [ + 'AWS4-HMAC-SHA256', + dateStamp, + credentialScope, + this.sha256(canonicalRequest), + ].join('\n'); + // Calculate signature + const signingKey = this.getSignatureKey(shortDate); + const signature = this.hmac(signingKey, stringToSign).toString('hex'); + // Authorization header + headers['authorization'] = `AWS4-HMAC-SHA256 Credential=${this.config.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; + return new Promise((resolve, reject) => { + const options = { + hostname: this.host.split(':')[0], + port: this.host.includes(':') ? parseInt(this.host.split(':')[1]) : (this.useHttps ? 443 : 80), + path: path, + method: method, + headers: headers, + }; + const transport = this.useHttps ? https : http; + const req = transport.request(options, (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + const data = Buffer.concat(chunks); + if (res.statusCode && res.statusCode >= 400) { + reject(new Error(`S3 error ${res.statusCode}: ${data.toString('utf-8').substring(0, 200)}`)); + } + else { + resolve(data); + } + }); + }); + req.on('error', reject); + req.setTimeout(30000, () => { req.destroy(); reject(new Error('S3 request timeout')); }); + if (body) + req.write(body); + req.end(); + }); + } + sha256(data) { + return crypto.createHash('sha256').update(data, 'utf8').digest('hex'); + } + hmac(key, data) { + return crypto.createHmac('sha256', key).update(data, 'utf8').digest(); + } + getSignatureKey(dateStamp) { + const kDate = this.hmac(`AWS4${this.config.secretAccessKey}`, dateStamp); + const kRegion = this.hmac(kDate, this.config.region); + const kService = this.hmac(kRegion, 's3'); + return this.hmac(kService, 'aws4_request'); + } +} +exports.S3Source = S3Source; +//# sourceMappingURL=s3Source.js.map \ No newline at end of file diff --git a/dist-server/s3Source.js.map b/dist-server/s3Source.js.map new file mode 100644 index 0000000..04e02ea --- /dev/null +++ b/dist-server/s3Source.js.map @@ -0,0 +1 @@ +{"version":3,"file":"s3Source.js","sourceRoot":"","sources":["../src/server/s3Source.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist-server/server.js b/dist-server/server.js new file mode 100644 index 0000000..96f7d16 --- /dev/null +++ b/dist-server/server.js @@ -0,0 +1,448 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +const http = __importStar(require("http")); +const fs = __importStar(require("fs")); +const path = __importStar(require("path")); +const url = __importStar(require("url")); +const logSourceManager_1 = require("./logSourceManager"); +const configManager_1 = require("./configManager"); +const collector_1 = require("./collector"); +const s3Source_1 = require("./s3Source"); +const configFile_1 = require("./configFile"); +const monitor_1 = require("./monitor"); +const configManager = new configManager_1.ConfigManager(); +const logSourceManager = new logSourceManager_1.LogSourceManager(configManager); +// Mode: "viewer" | "collector" | "monitor" | "both" | "all" +const MODE = (process.env.LOG_MODE || 'both').toLowerCase(); +const PORT = parseInt(process.env.PORT || '8080', 10); +const HOST = process.env.HOST || '0.0.0.0'; +// Collector setup +let collector = null; +if (MODE === 'collector' || MODE === 'both') { + let forwardTargets = []; + try { + forwardTargets = JSON.parse(process.env.COLLECTOR_FORWARD_TARGETS || '[]'); + } + catch { } + collector = new collector_1.LogCollector({ + storageDir: process.env.COLLECTOR_STORAGE_DIR || path.join(process.cwd(), 'collector-data'), + maxLinesPerStream: parseInt(process.env.COLLECTOR_MAX_LINES || '100000', 10), + maxStreams: parseInt(process.env.COLLECTOR_MAX_STREAMS || '50', 10), + udpEnabled: process.env.COLLECTOR_UDP_ENABLED !== 'false', + udpPort: parseInt(process.env.COLLECTOR_UDP_PORT || '5140', 10), + persistToDisk: process.env.COLLECTOR_PERSIST !== 'false', + rotation: { + maxSizeBytes: parseInt(process.env.COLLECTOR_ROTATION_MAX_SIZE || '5242880', 10), + maxAgeMs: parseInt(process.env.COLLECTOR_ROTATION_MAX_AGE || '600000', 10), + compression: process.env.COLLECTOR_ROTATION_COMPRESSION || 'gzip', + fileExtension: process.env.COLLECTOR_ROTATION_EXTENSION || '.log', + }, + forwardTargets, + }); +} +// S3 sources from env +const s3Sources = new Map(); +function loadS3SourcesFromEnv() { + // Format: S3_SOURCE_=bucket:prefix + // S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_REGION (global) + // Or per-source: S3_SOURCE__ACCESS_KEY, S3_SOURCE__SECRET_KEY, etc. + const globalAccessKey = process.env.S3_ACCESS_KEY_ID || process.env.AWS_ACCESS_KEY_ID || ''; + const globalSecretKey = process.env.S3_SECRET_ACCESS_KEY || process.env.AWS_SECRET_ACCESS_KEY || ''; + const globalRegion = process.env.S3_REGION || process.env.AWS_REGION || 'us-east-1'; + const globalEndpoint = process.env.S3_ENDPOINT || ''; + for (const [key, value] of Object.entries(process.env)) { + if (key.startsWith('S3_SOURCE_') && value && !key.includes('_ACCESS_KEY') && !key.includes('_SECRET_KEY') && !key.includes('_REGION') && !key.includes('_ENDPOINT')) { + const name = key.replace('S3_SOURCE_', '').toLowerCase(); + const parts = value.split(':'); + const bucket = parts[0]; + const prefix = parts.slice(1).join(':') || ''; + const accessKey = process.env[`S3_SOURCE_${name.toUpperCase()}_ACCESS_KEY`] || globalAccessKey; + const secretKey = process.env[`S3_SOURCE_${name.toUpperCase()}_SECRET_KEY`] || globalSecretKey; + const region = process.env[`S3_SOURCE_${name.toUpperCase()}_REGION`] || globalRegion; + const endpoint = process.env[`S3_SOURCE_${name.toUpperCase()}_ENDPOINT`] || globalEndpoint; + if (accessKey && secretKey && bucket) { + const config = { accessKeyId: accessKey, secretAccessKey: secretKey, region, bucket, prefix: prefix || undefined, endpoint: endpoint || undefined }; + s3Sources.set(name, new s3Source_1.S3Source(config)); + } + } + } +} +loadS3SourcesFromEnv(); +// Monitor setup +let monitor = null; +if (MODE === 'monitor' || MODE === 'both' || MODE === 'all') { + let monitorRules = []; + try { + monitorRules = JSON.parse(process.env.MONITOR_RULES || '[]'); + } + catch { } + monitor = new monitor_1.LogMonitor({ + enabled: process.env.MONITOR_ENABLED !== 'false', + scanIntervalMs: parseInt(process.env.MONITOR_SCAN_INTERVAL || '60000', 10), + rules: monitorRules, + storageDir: process.env.MONITOR_STORAGE_DIR || path.join(process.cwd(), 'monitor-data'), + }); +} +// Remote collector URL (for viewer mode connecting to a remote collector) +const REMOTE_COLLECTOR_URL = process.env.REMOTE_COLLECTOR_URL || ''; +const MIME_TYPES = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', +}; +function sendJson(res, data, status = 200) { + res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify(data)); +} +function sendError(res, message, status = 500) { + sendJson(res, { error: message }, status); +} +async function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); + req.on('error', reject); + }); +} +async function fetchRemote(remotePath) { + if (!REMOTE_COLLECTOR_URL) + throw new Error('No remote collector configured'); + const fullUrl = REMOTE_COLLECTOR_URL.replace(/\/$/, '') + remotePath; + return new Promise((resolve, reject) => { + const transport = fullUrl.startsWith('https') ? require('https') : require('http'); + transport.get(fullUrl, (res) => { + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8'))); + } + catch { + resolve(Buffer.concat(chunks).toString('utf-8')); + } + }); + }).on('error', reject); + }); +} +const server = http.createServer(async (req, res) => { + const parsedUrl = url.parse(req.url || '/', true); + const pathname = parsedUrl.pathname || '/'; + const method = req.method || 'GET'; + // CORS + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Stream-Name'); + if (method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } + // ---- Collector endpoints ---- + if (collector && (pathname.startsWith('/collect/') || pathname.startsWith('/collector/'))) { + const handled = collector.handleRequest(req, res, pathname); + if (handled) + return; + } + // ---- Monitor endpoints ---- + if (monitor && pathname.startsWith('/api/monitor/')) { + const handled = monitor.handleRequest(req, res, pathname); + if (handled) + return; + } + // ---- API Routes ---- + if (pathname.startsWith('/api/')) { + try { + // GET /api/mode + if (pathname === '/api/mode' && method === 'GET') { + return sendJson(res, { mode: MODE, hasCollector: !!collector, hasRemoteCollector: !!REMOTE_COLLECTOR_URL, s3Sources: Array.from(s3Sources.keys()) }); + } + // GET /api/config + if (pathname === '/api/config' && method === 'GET') { + return sendJson(res, { ...configManager.getConfig(), mode: MODE }); + } + // GET /api/sources + if (pathname === '/api/sources' && method === 'GET') { + return sendJson(res, logSourceManager.getSources()); + } + // POST /api/sources { path: "..." } + if (pathname === '/api/sources' && method === 'POST') { + const body = JSON.parse(await readBody(req)); + logSourceManager.addSource(body.path); + return sendJson(res, logSourceManager.getSources()); + } + // DELETE /api/sources?path=... + if (pathname === '/api/sources' && method === 'DELETE') { + const sourcePath = parsedUrl.query.path; + if (!sourcePath) + return sendError(res, 'Missing path parameter', 400); + logSourceManager.removeSource(sourcePath); + return sendJson(res, logSourceManager.getSources()); + } + // GET /api/logs?source=... + if (pathname === '/api/logs' && method === 'GET') { + const source = parsedUrl.query.source; + if (source && source !== 'ALL') { + const entries = await logSourceManager.readLog(source); + return sendJson(res, entries); + } + else { + const entries = await logSourceManager.readAllLogs(); + return sendJson(res, entries); + } + } + // ---- S3 endpoints ---- + // POST /api/s3/add { name, bucket, prefix, accessKeyId, secretAccessKey, region, endpoint? } + if (pathname === '/api/s3/add' && method === 'POST') { + const body = JSON.parse(await readBody(req)); + const { name, bucket, prefix, accessKeyId, secretAccessKey, region, endpoint } = body; + if (!name || !bucket || !accessKeyId || !secretAccessKey || !region) { + return sendError(res, 'Missing required fields: name, bucket, accessKeyId, secretAccessKey, region', 400); + } + const config = { accessKeyId, secretAccessKey, region, bucket, prefix, endpoint }; + s3Sources.set(name, new s3Source_1.S3Source(config)); + return sendJson(res, { ok: true, name }); + } + // GET /api/s3/sources + if (pathname === '/api/s3/sources' && method === 'GET') { + return sendJson(res, Array.from(s3Sources.keys())); + } + // GET /api/s3/list?name=...&prefix=... + if (pathname === '/api/s3/list' && method === 'GET') { + const name = parsedUrl.query.name; + const prefix = parsedUrl.query.prefix; + const s3 = s3Sources.get(name); + if (!s3) + return sendError(res, `S3 source not found: ${name}`, 404); + const objects = await s3.listObjects(prefix); + return sendJson(res, objects); + } + // GET /api/s3/read?name=...&key=... + if (pathname === '/api/s3/read' && method === 'GET') { + const name = parsedUrl.query.name; + const key = parsedUrl.query.key; + const s3 = s3Sources.get(name); + if (!s3) + return sendError(res, `S3 source not found: ${name}`, 404); + if (!key) + return sendError(res, 'Missing key parameter', 400); + const content = await s3.getObject(key); + const lines = content.split('\n').filter(l => l.trim() !== ''); + const entries = lines.map((line, i) => ({ + line, + lineNumber: i + 1, + source: `s3://${name}/${key}`, + timestamp: extractTimestamp(line), + level: extractLevel(line), + })); + return sendJson(res, entries); + } + // DELETE /api/s3/remove?name=... + if (pathname === '/api/s3/remove' && method === 'DELETE') { + const name = parsedUrl.query.name; + if (!name) + return sendError(res, 'Missing name parameter', 400); + s3Sources.delete(name); + return sendJson(res, { ok: true }); + } + // ---- Remote Collector endpoints (viewer reads from remote collector) ---- + // GET /api/remote/streams + if (pathname === '/api/remote/streams' && method === 'GET') { + if (!REMOTE_COLLECTOR_URL) + return sendJson(res, []); + try { + const streams = await fetchRemote('/collector/streams'); + return sendJson(res, streams); + } + catch (err) { + return sendError(res, `Cannot reach remote collector: ${err.message}`); + } + } + // GET /api/remote/stream/:id?tail=N + const remoteStreamMatch = pathname.match(/^\/api\/remote\/stream\/([a-zA-Z0-9_\-\.]+)$/); + if (remoteStreamMatch && method === 'GET') { + const streamId = remoteStreamMatch[1]; + const tail = parsedUrl.query.tail; + const tailParam = tail ? `?tail=${tail}` : ''; + try { + const data = await fetchRemote(`/collector/stream/${streamId}${tailParam}`); + // Convert to LogEntry format + const lines = data.lines || []; + const entries = lines.map((line, i) => ({ + line, + lineNumber: i + 1, + source: `collector://${streamId}`, + timestamp: extractTimestamp(line), + level: extractLevel(line), + })); + return sendJson(res, entries); + } + catch (err) { + return sendError(res, err.message); + } + } + // ---- Collector streams as log source (local) ---- + // GET /api/collector/streams + if (pathname === '/api/collector/streams' && method === 'GET') { + if (!collector) + return sendJson(res, []); + return sendJson(res, collector.getStreams()); + } + // GET /api/collector/stream/:id + const localStreamMatch = pathname.match(/^\/api\/collector\/stream\/([a-zA-Z0-9_\-\.]+)$/); + if (localStreamMatch && method === 'GET') { + const streamId = localStreamMatch[1]; + if (!collector) + return sendError(res, 'Collector not enabled', 400); + try { + const tail = parsedUrl.query.tail; + const lines = collector.readStream(streamId, tail ? parseInt(tail, 10) : undefined); + const entries = lines.map((line, i) => ({ + line, + lineNumber: i + 1, + source: `stream://${streamId}`, + timestamp: extractTimestamp(line), + level: extractLevel(line), + })); + return sendJson(res, entries); + } + catch (err) { + return sendError(res, err.message, 404); + } + } + // POST /api/config/save - Save config to file + if (pathname === '/api/config/save' && method === 'POST') { + const body = JSON.parse(await readBody(req)); + (0, configFile_1.saveConfigFile)(body); + return sendJson(res, { ok: true }); + } + return sendError(res, 'Not found', 404); + } + catch (err) { + console.error('API error:', err); + return sendError(res, err.message); + } + } + // ---- Static Files ---- + const webDir = path.join(__dirname, '..', 'src', 'web'); + let filePath; + // Serve collector UI when in collector mode, monitor UI when in monitor mode + if (pathname === '/' || pathname === '/index.html') { + if (MODE === 'collector') { + filePath = path.join(webDir, 'collector.html'); + } + else if (MODE === 'monitor') { + filePath = path.join(webDir, 'monitor.html'); + } + else { + filePath = path.join(webDir, 'index.html'); + } + } + else { + filePath = path.join(webDir, pathname); + } + const webRoot = path.resolve(webDir); + const resolvedPath = path.resolve(filePath); + if (!resolvedPath.startsWith(webRoot)) { + res.writeHead(403); + res.end('Forbidden'); + return; + } + try { + if (!fs.existsSync(resolvedPath) || fs.statSync(resolvedPath).isDirectory()) { + res.writeHead(404); + res.end('Not found'); + return; + } + const ext = path.extname(resolvedPath); + const contentType = MIME_TYPES[ext] || 'application/octet-stream'; + res.writeHead(200, { 'Content-Type': contentType }); + res.end(fs.readFileSync(resolvedPath)); + } + catch { + res.writeHead(500); + res.end('Internal server error'); + } +}); +// Helper functions +function extractTimestamp(line) { + const patterns = [ + /(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)/, + /^([A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})/, + /\[(\d{2}\/[A-Z][a-z]{2}\/\d{4}:\d{2}:\d{2}:\d{2}\s[+-]\d{4})\]/, + ]; + for (const p of patterns) { + const m = line.match(p); + if (m) + return m[1]; + } + return null; +} +function extractLevel(line) { + const upper = line.toUpperCase(); + for (const level of ['FATAL', 'ERROR', 'WARN', 'WARNING', 'INFO', 'DEBUG', 'TRACE']) { + if (new RegExp(`\\b${level}\\b`).test(upper)) + return level === 'WARNING' ? 'WARN' : level; + } + return null; +} +server.listen(PORT, HOST, () => { + console.log(''); + console.log(' ╔══════════════════════════════════════════════════╗'); + console.log(` ║ 📋 Log Viewer - Mode: ${MODE.toUpperCase().padEnd(24)}║`); + console.log(' ╠══════════════════════════════════════════════════╣'); + console.log(` ║ URL: http://${HOST}:${PORT} ║`); + console.log(` ║ Sources: ${String(logSourceManager.getSources().length).padEnd(3)} file(s) ║`); + console.log(` ║ S3 Sources: ${String(s3Sources.size).padEnd(3)} configured ║`); + console.log(` ║ Collector: ${collector ? 'ACTIVE' : 'OFF'} ║`); + console.log(` ║ Monitor: ${monitor ? 'ACTIVE' : 'OFF'} ║`); + if (REMOTE_COLLECTOR_URL) { + console.log(` ║ Remote: ${REMOTE_COLLECTOR_URL.substring(0, 30).padEnd(30)} ║`); + } + console.log(' ╚══════════════════════════════════════════════════╝'); + console.log(''); + if (collector) { + console.log(' Collector endpoints:'); + console.log(` POST http://${HOST}:${PORT}/collect/ (plain text, one line per line)`); + console.log(` POST http://${HOST}:${PORT}/collect//json (JSON array of entries)`); + console.log(''); + } +}); +//# sourceMappingURL=server.js.map \ No newline at end of file diff --git a/dist-server/server.js.map b/dist-server/server.js.map new file mode 100644 index 0000000..159e870 --- /dev/null +++ b/dist-server/server.js.map @@ -0,0 +1 @@ +{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server/server.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,2CAA6B;AAC7B,uCAAyB;AACzB,2CAA6B;AAC7B,yCAA2B;AAC3B,yDAAsD;AACtD,mDAAgD;AAChD,2CAA2C;AAC3C,yCAAgD;AAChD,6CAA8C;AAC9C,uCAAsD;AAEtD,MAAM,aAAa,GAAG,IAAI,6BAAa,EAAE,CAAC;AAC1C,MAAM,gBAAgB,GAAG,IAAI,mCAAgB,CAAC,aAAa,CAAC,CAAC;AAE7D,4DAA4D;AAC5D,MAAM,IAAI,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,IAAI,MAAM,CAAC,CAAC,WAAW,EAAE,CAAC;AAC5D,MAAM,IAAI,GAAG,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,MAAM,EAAE,EAAE,CAAC,CAAC;AACtD,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,SAAS,CAAC;AAE3C,kBAAkB;AAClB,IAAI,SAAS,GAAwB,IAAI,CAAC;AAC1C,IAAI,IAAI,KAAK,WAAW,IAAI,IAAI,KAAK,MAAM,EAAE,CAAC;IAC5C,IAAI,cAAc,GAAU,EAAE,CAAC;IAC/B,IAAI,CAAC;QAAC,cAAc,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,yBAAyB,IAAI,IAAI,CAAC,CAAC;IAAC,CAAC;IAAC,MAAM,CAAC,CAAC,CAAC;IAE7F,SAAS,GAAG,IAAI,wBAAY,CAAC;QAC3B,UAAU,EAAE,OAAO,CAAC,GAAG,CAAC,qBAAqB,IAAI,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,gBAAgB,CAAC;QAC3F,iBAAiB,EAAE,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,mBAAmB,IAAI,QAAQ,EAAE,EAAE,CAAC;QAC5E,UAAU,EAAE,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,qBAAqB,IAAI,IAAI,EAAE,EAAE,CAAC;QACnE,UAAU,EAAE,OAAO,CAAC,GAAG,CAAC,qBAAqB,KAAK,OAAO;QACzD,OAAO,EAAE,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,kBAAkB,IAAI,MAAM,EAAE,EAAE,CAAC;QAC/D,aAAa,EAAE,OAAO,CAAC,GAAG,CAAC,iBAAiB,KAAK,OAAO;QACxD,QAAQ,EAAE;YACR,YAAY,EAAE,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,2BAA2B,IAAI,SAAS,EAAE,EAAE,CAAC;YAChF,QAAQ,EAAE,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,0BAA0B,IAAI,QAAQ,EAAE,EAAE,CAAC;YAC1E,WAAW,EAAG,OAAO,CAAC,GAAG,CAAC,8BAAsC,IAAI,MAAM;YAC1E,aAAa,EAAE,OAAO,CAAC,GAAG,CAAC,4BAA4B,IAAI,MAAM;SAClE;QACD,cAAc;KACf,CAAC,CAAC;AACL,CAAC;AAED,sBAAsB;AACtB,MAAM,SAAS,GAA0B,IAAI,GAAG,EAAE,CAAC;AACnD,SAAS,oBAAoB;IAC3B,yCAAyC;IACzC,6DAA6D;IAC7D,gFAAgF;IAChF,MAAM,eAAe,GAAG,OAAO,CAAC,GAAG,CAAC,gBAAgB,IAAI,OAAO,CAAC,GAAG,CAAC,iBAAiB,IAAI,EAAE,CAAC;IAC5F,MAAM,eAAe,GAAG,OAAO,CAAC,GAAG,CAAC,oBAAoB,IAAI,OAAO,CAAC,GAAG,CAAC,qBAAqB,IAAI,EAAE,CAAC;IACpG,MAAM,YAAY,GAAG,OAAO,CAAC,GAAG,CAAC,SAAS,IAAI,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,WAAW,CAAC;IACpF,MAAM,cAAc,GAAG,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,EAAE,CAAC;IAErD,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC;QACvD,IAAI,GAAG,CAAC,UAAU,CAAC,YAAY,CAAC,IAAI,KAAK,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,aAAa,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,aAAa,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,SAAS,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,WAAW,CAAC,EAAE,CAAC;YACpK,MAAM,IAAI,GAAG,GAAG,CAAC,OAAO,CAAC,YAAY,EAAE,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC;YACzD,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;YAC/B,MAAM,MAAM,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;YACxB,MAAM,MAAM,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC;YAE9C,MAAM,SAAS,GAAG,OAAO,CAAC,GAAG,CAAC,aAAa,IAAI,CAAC,WAAW,EAAE,aAAa,CAAC,IAAI,eAAe,CAAC;YAC/F,MAAM,SAAS,GAAG,OAAO,CAAC,GAAG,CAAC,aAAa,IAAI,CAAC,WAAW,EAAE,aAAa,CAAC,IAAI,eAAe,CAAC;YAC/F,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,aAAa,IAAI,CAAC,WAAW,EAAE,SAAS,CAAC,IAAI,YAAY,CAAC;YACrF,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,aAAa,IAAI,CAAC,WAAW,EAAE,WAAW,CAAC,IAAI,cAAc,CAAC;YAE3F,IAAI,SAAS,IAAI,SAAS,IAAI,MAAM,EAAE,CAAC;gBACrC,MAAM,MAAM,GAAa,EAAE,WAAW,EAAE,SAAS,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,IAAI,SAAS,EAAE,QAAQ,EAAE,QAAQ,IAAI,SAAS,EAAE,CAAC;gBAC9J,SAAS,CAAC,GAAG,CAAC,IAAI,EAAE,IAAI,mBAAQ,CAAC,MAAM,CAAC,CAAC,CAAC;YAC5C,CAAC;QACH,CAAC;IACH,CAAC;AACH,CAAC;AACD,oBAAoB,EAAE,CAAC;AAEvB,gBAAgB;AAChB,IAAI,OAAO,GAAsB,IAAI,CAAC;AACtC,IAAI,IAAI,KAAK,SAAS,IAAI,IAAI,KAAK,MAAM,IAAI,IAAI,KAAK,KAAK,EAAE,CAAC;IAC5D,IAAI,YAAY,GAAU,EAAE,CAAC;IAC7B,IAAI,CAAC;QAAC,YAAY,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,aAAa,IAAI,IAAI,CAAC,CAAC;IAAC,CAAC;IAAC,MAAM,CAAC,CAAC,CAAC;IAE/E,OAAO,GAAG,IAAI,oBAAU,CAAC;QACvB,OAAO,EAAE,OAAO,CAAC,GAAG,CAAC,eAAe,KAAK,OAAO;QAChD,cAAc,EAAE,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,qBAAqB,IAAI,OAAO,EAAE,EAAE,CAAC;QAC1E,KAAK,EAAE,YAAY;QACnB,UAAU,EAAE,OAAO,CAAC,GAAG,CAAC,mBAAmB,IAAI,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,cAAc,CAAC;KACxF,CAAC,CAAC;AACL,CAAC;AAED,0EAA0E;AAC1E,MAAM,oBAAoB,GAAG,OAAO,CAAC,GAAG,CAAC,oBAAoB,IAAI,EAAE,CAAC;AAEpE,MAAM,UAAU,GAA2B;IACzC,OAAO,EAAE,0BAA0B;IACnC,MAAM,EAAE,yBAAyB;IACjC,KAAK,EAAE,uCAAuC;IAC9C,OAAO,EAAE,iCAAiC;IAC1C,MAAM,EAAE,WAAW;IACnB,MAAM,EAAE,eAAe;IACvB,MAAM,EAAE,cAAc;CACvB,CAAC;AAEF,SAAS,QAAQ,CAAC,GAAwB,EAAE,IAAa,EAAE,MAAM,GAAG,GAAG;IACrE,GAAG,CAAC,SAAS,CAAC,MAAM,EAAE,EAAE,cAAc,EAAE,iCAAiC,EAAE,CAAC,CAAC;IAC7E,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC;AAChC,CAAC;AAED,SAAS,SAAS,CAAC,GAAwB,EAAE,OAAe,EAAE,MAAM,GAAG,GAAG;IACxE,QAAQ,CAAC,GAAG,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,MAAM,CAAC,CAAC;AAC5C,CAAC;AAED,KAAK,UAAU,QAAQ,CAAC,GAAyB;IAC/C,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,MAAM,MAAM,GAAa,EAAE,CAAC;QAC5B,GAAG,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAa,EAAE,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC;QACtD,GAAG,CAAC,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;QACtE,GAAG,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;IAC1B,CAAC,CAAC,CAAC;AACL,CAAC;AAED,KAAK,UAAU,WAAW,CAAC,UAAkB;IAC3C,IAAI,CAAC,oBAAoB;QAAE,MAAM,IAAI,KAAK,CAAC,gCAAgC,CAAC,CAAC;IAC7E,MAAM,OAAO,GAAG,oBAAoB,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,GAAG,UAAU,CAAC;IAErE,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,MAAM,SAAS,GAAG,OAAO,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;QACnF,SAAS,CAAC,GAAG,CAAC,OAAO,EAAE,CAAC,GAAyB,EAAE,EAAE;YACnD,MAAM,MAAM,GAAa,EAAE,CAAC;YAC5B,GAAG,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,CAAS,EAAE,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;YAC9C,GAAG,CAAC,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE;gBACjB,IAAI,CAAC;oBACH,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;gBAC/D,CAAC;gBAAC,MAAM,CAAC;oBAAC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC;gBAAC,CAAC;YAC/D,CAAC,CAAC,CAAC;QACL,CAAC,CAAC,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;IACzB,CAAC,CAAC,CAAC;AACL,CAAC;AAED,MAAM,MAAM,GAAG,IAAI,CAAC,YAAY,CAAC,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,EAAE;IAClD,MAAM,SAAS,GAAG,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,IAAI,GAAG,EAAE,IAAI,CAAC,CAAC;IAClD,MAAM,QAAQ,GAAG,SAAS,CAAC,QAAQ,IAAI,GAAG,CAAC;IAC3C,MAAM,MAAM,GAAG,GAAG,CAAC,MAAM,IAAI,KAAK,CAAC;IAEnC,OAAO;IACP,GAAG,CAAC,SAAS,CAAC,6BAA6B,EAAE,GAAG,CAAC,CAAC;IAClD,GAAG,CAAC,SAAS,CAAC,8BAA8B,EAAE,4BAA4B,CAAC,CAAC;IAC5E,GAAG,CAAC,SAAS,CAAC,8BAA8B,EAAE,6BAA6B,CAAC,CAAC;IAE7E,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;QAAC,GAAG,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;QAAC,GAAG,CAAC,GAAG,EAAE,CAAC;QAAC,OAAO;IAAC,CAAC;IAEpE,gCAAgC;IAChC,IAAI,SAAS,IAAI,CAAC,QAAQ,CAAC,UAAU,CAAC,WAAW,CAAC,IAAI,QAAQ,CAAC,UAAU,CAAC,aAAa,CAAC,CAAC,EAAE,CAAC;QAC1F,MAAM,OAAO,GAAG,SAAS,CAAC,aAAa,CAAC,GAAG,EAAE,GAAG,EAAE,QAAQ,CAAC,CAAC;QAC5D,IAAI,OAAO;YAAE,OAAO;IACtB,CAAC;IAED,8BAA8B;IAC9B,IAAI,OAAO,IAAI,QAAQ,CAAC,UAAU,CAAC,eAAe,CAAC,EAAE,CAAC;QACpD,MAAM,OAAO,GAAG,OAAO,CAAC,aAAa,CAAC,GAAG,EAAE,GAAG,EAAE,QAAQ,CAAC,CAAC;QAC1D,IAAI,OAAO;YAAE,OAAO;IACtB,CAAC;IAED,uBAAuB;IACvB,IAAI,QAAQ,CAAC,UAAU,CAAC,OAAO,CAAC,EAAE,CAAC;QACjC,IAAI,CAAC;YACH,gBAAgB;YAChB,IAAI,QAAQ,KAAK,WAAW,IAAI,MAAM,KAAK,KAAK,EAAE,CAAC;gBACjD,OAAO,QAAQ,CAAC,GAAG,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,YAAY,EAAE,CAAC,CAAC,SAAS,EAAE,kBAAkB,EAAE,CAAC,CAAC,oBAAoB,EAAE,SAAS,EAAE,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,EAAE,CAAC,EAAE,CAAC,CAAC;YACvJ,CAAC;YAED,kBAAkB;YAClB,IAAI,QAAQ,KAAK,aAAa,IAAI,MAAM,KAAK,KAAK,EAAE,CAAC;gBACnD,OAAO,QAAQ,CAAC,GAAG,EAAE,EAAE,GAAG,aAAa,CAAC,SAAS,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;YACrE,CAAC;YAED,mBAAmB;YACnB,IAAI,QAAQ,KAAK,cAAc,IAAI,MAAM,KAAK,KAAK,EAAE,CAAC;gBACpD,OAAO,QAAQ,CAAC,GAAG,EAAE,gBAAgB,CAAC,UAAU,EAAE,CAAC,CAAC;YACtD,CAAC;YAED,qCAAqC;YACrC,IAAI,QAAQ,KAAK,cAAc,IAAI,MAAM,KAAK,MAAM,EAAE,CAAC;gBACrD,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC;gBAC7C,gBAAgB,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;gBACtC,OAAO,QAAQ,CAAC,GAAG,EAAE,gBAAgB,CAAC,UAAU,EAAE,CAAC,CAAC;YACtD,CAAC;YAED,+BAA+B;YAC/B,IAAI,QAAQ,KAAK,cAAc,IAAI,MAAM,KAAK,QAAQ,EAAE,CAAC;gBACvD,MAAM,UAAU,GAAG,SAAS,CAAC,KAAK,CAAC,IAAc,CAAC;gBAClD,IAAI,CAAC,UAAU;oBAAE,OAAO,SAAS,CAAC,GAAG,EAAE,wBAAwB,EAAE,GAAG,CAAC,CAAC;gBACtE,gBAAgB,CAAC,YAAY,CAAC,UAAU,CAAC,CAAC;gBAC1C,OAAO,QAAQ,CAAC,GAAG,EAAE,gBAAgB,CAAC,UAAU,EAAE,CAAC,CAAC;YACtD,CAAC;YAED,2BAA2B;YAC3B,IAAI,QAAQ,KAAK,WAAW,IAAI,MAAM,KAAK,KAAK,EAAE,CAAC;gBACjD,MAAM,MAAM,GAAG,SAAS,CAAC,KAAK,CAAC,MAA4B,CAAC;gBAC5D,IAAI,MAAM,IAAI,MAAM,KAAK,KAAK,EAAE,CAAC;oBAC/B,MAAM,OAAO,GAAG,MAAM,gBAAgB,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;oBACvD,OAAO,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;gBAChC,CAAC;qBAAM,CAAC;oBACN,MAAM,OAAO,GAAG,MAAM,gBAAgB,CAAC,WAAW,EAAE,CAAC;oBACrD,OAAO,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;gBAChC,CAAC;YACH,CAAC;YAED,yBAAyB;YAEzB,8FAA8F;YAC9F,IAAI,QAAQ,KAAK,aAAa,IAAI,MAAM,KAAK,MAAM,EAAE,CAAC;gBACpD,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC;gBAC7C,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,IAAI,CAAC;gBACtF,IAAI,CAAC,IAAI,IAAI,CAAC,MAAM,IAAI,CAAC,WAAW,IAAI,CAAC,eAAe,IAAI,CAAC,MAAM,EAAE,CAAC;oBACpE,OAAO,SAAS,CAAC,GAAG,EAAE,6EAA6E,EAAE,GAAG,CAAC,CAAC;gBAC5G,CAAC;gBACD,MAAM,MAAM,GAAa,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC;gBAC5F,SAAS,CAAC,GAAG,CAAC,IAAI,EAAE,IAAI,mBAAQ,CAAC,MAAM,CAAC,CAAC,CAAC;gBAC1C,OAAO,QAAQ,CAAC,GAAG,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;YAC3C,CAAC;YAED,sBAAsB;YACtB,IAAI,QAAQ,KAAK,iBAAiB,IAAI,MAAM,KAAK,KAAK,EAAE,CAAC;gBACvD,OAAO,QAAQ,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;YACrD,CAAC;YAED,uCAAuC;YACvC,IAAI,QAAQ,KAAK,cAAc,IAAI,MAAM,KAAK,KAAK,EAAE,CAAC;gBACpD,MAAM,IAAI,GAAG,SAAS,CAAC,KAAK,CAAC,IAAc,CAAC;gBAC5C,MAAM,MAAM,GAAG,SAAS,CAAC,KAAK,CAAC,MAA4B,CAAC;gBAC5D,MAAM,EAAE,GAAG,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;gBAC/B,IAAI,CAAC,EAAE;oBAAE,OAAO,SAAS,CAAC,GAAG,EAAE,wBAAwB,IAAI,EAAE,EAAE,GAAG,CAAC,CAAC;gBACpE,MAAM,OAAO,GAAG,MAAM,EAAE,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;gBAC7C,OAAO,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;YAChC,CAAC;YAED,oCAAoC;YACpC,IAAI,QAAQ,KAAK,cAAc,IAAI,MAAM,KAAK,KAAK,EAAE,CAAC;gBACpD,MAAM,IAAI,GAAG,SAAS,CAAC,KAAK,CAAC,IAAc,CAAC;gBAC5C,MAAM,GAAG,GAAG,SAAS,CAAC,KAAK,CAAC,GAAa,CAAC;gBAC1C,MAAM,EAAE,GAAG,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;gBAC/B,IAAI,CAAC,EAAE;oBAAE,OAAO,SAAS,CAAC,GAAG,EAAE,wBAAwB,IAAI,EAAE,EAAE,GAAG,CAAC,CAAC;gBACpE,IAAI,CAAC,GAAG;oBAAE,OAAO,SAAS,CAAC,GAAG,EAAE,uBAAuB,EAAE,GAAG,CAAC,CAAC;gBAC9D,MAAM,OAAO,GAAG,MAAM,EAAE,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;gBACxC,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;gBAC/D,MAAM,OAAO,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC;oBACtC,IAAI;oBACJ,UAAU,EAAE,CAAC,GAAG,CAAC;oBACjB,MAAM,EAAE,QAAQ,IAAI,IAAI,GAAG,EAAE;oBAC7B,SAAS,EAAE,gBAAgB,CAAC,IAAI,CAAC;oBACjC,KAAK,EAAE,YAAY,CAAC,IAAI,CAAC;iBAC1B,CAAC,CAAC,CAAC;gBACJ,OAAO,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;YAChC,CAAC;YAED,iCAAiC;YACjC,IAAI,QAAQ,KAAK,gBAAgB,IAAI,MAAM,KAAK,QAAQ,EAAE,CAAC;gBACzD,MAAM,IAAI,GAAG,SAAS,CAAC,KAAK,CAAC,IAAc,CAAC;gBAC5C,IAAI,CAAC,IAAI;oBAAE,OAAO,SAAS,CAAC,GAAG,EAAE,wBAAwB,EAAE,GAAG,CAAC,CAAC;gBAChE,SAAS,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;gBACvB,OAAO,QAAQ,CAAC,GAAG,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC;YACrC,CAAC;YAED,4EAA4E;YAE5E,0BAA0B;YAC1B,IAAI,QAAQ,KAAK,qBAAqB,IAAI,MAAM,KAAK,KAAK,EAAE,CAAC;gBAC3D,IAAI,CAAC,oBAAoB;oBAAE,OAAO,QAAQ,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;gBACpD,IAAI,CAAC;oBACH,MAAM,OAAO,GAAG,MAAM,WAAW,CAAC,oBAAoB,CAAC,CAAC;oBACxD,OAAO,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;gBAChC,CAAC;gBAAC,OAAO,GAAG,EAAE,CAAC;oBACb,OAAO,SAAS,CAAC,GAAG,EAAE,kCAAmC,GAAa,CAAC,OAAO,EAAE,CAAC,CAAC;gBACpF,CAAC;YACH,CAAC;YAED,oCAAoC;YACpC,MAAM,iBAAiB,GAAG,QAAQ,CAAC,KAAK,CAAC,8CAA8C,CAAC,CAAC;YACzF,IAAI,iBAAiB,IAAI,MAAM,KAAK,KAAK,EAAE,CAAC;gBAC1C,MAAM,QAAQ,GAAG,iBAAiB,CAAC,CAAC,CAAC,CAAC;gBACtC,MAAM,IAAI,GAAG,SAAS,CAAC,KAAK,CAAC,IAA0B,CAAC;gBACxD,MAAM,SAAS,GAAG,IAAI,CAAC,CAAC,CAAC,SAAS,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;gBAC9C,IAAI,CAAC;oBACH,MAAM,IAAI,GAAG,MAAM,WAAW,CAAC,qBAAqB,QAAQ,GAAG,SAAS,EAAE,CAAC,CAAC;oBAC5E,6BAA6B;oBAC7B,MAAM,KAAK,GAAa,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC;oBACzC,MAAM,OAAO,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,IAAY,EAAE,CAAS,EAAE,EAAE,CAAC,CAAC;wBACtD,IAAI;wBACJ,UAAU,EAAE,CAAC,GAAG,CAAC;wBACjB,MAAM,EAAE,eAAe,QAAQ,EAAE;wBACjC,SAAS,EAAE,gBAAgB,CAAC,IAAI,CAAC;wBACjC,KAAK,EAAE,YAAY,CAAC,IAAI,CAAC;qBAC1B,CAAC,CAAC,CAAC;oBACJ,OAAO,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;gBAChC,CAAC;gBAAC,OAAO,GAAG,EAAE,CAAC;oBACb,OAAO,SAAS,CAAC,GAAG,EAAG,GAAa,CAAC,OAAO,CAAC,CAAC;gBAChD,CAAC;YACH,CAAC;YAED,oDAAoD;YAEpD,6BAA6B;YAC7B,IAAI,QAAQ,KAAK,wBAAwB,IAAI,MAAM,KAAK,KAAK,EAAE,CAAC;gBAC9D,IAAI,CAAC,SAAS;oBAAE,OAAO,QAAQ,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;gBACzC,OAAO,QAAQ,CAAC,GAAG,EAAE,SAAS,CAAC,UAAU,EAAE,CAAC,CAAC;YAC/C,CAAC;YAED,gCAAgC;YAChC,MAAM,gBAAgB,GAAG,QAAQ,CAAC,KAAK,CAAC,iDAAiD,CAAC,CAAC;YAC3F,IAAI,gBAAgB,IAAI,MAAM,KAAK,KAAK,EAAE,CAAC;gBACzC,MAAM,QAAQ,GAAG,gBAAgB,CAAC,CAAC,CAAC,CAAC;gBACrC,IAAI,CAAC,SAAS;oBAAE,OAAO,SAAS,CAAC,GAAG,EAAE,uBAAuB,EAAE,GAAG,CAAC,CAAC;gBACpE,IAAI,CAAC;oBACH,MAAM,IAAI,GAAG,SAAS,CAAC,KAAK,CAAC,IAA0B,CAAC;oBACxD,MAAM,KAAK,GAAG,SAAS,CAAC,UAAU,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;oBACpF,MAAM,OAAO,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC;wBACtC,IAAI;wBACJ,UAAU,EAAE,CAAC,GAAG,CAAC;wBACjB,MAAM,EAAE,YAAY,QAAQ,EAAE;wBAC9B,SAAS,EAAE,gBAAgB,CAAC,IAAI,CAAC;wBACjC,KAAK,EAAE,YAAY,CAAC,IAAI,CAAC;qBAC1B,CAAC,CAAC,CAAC;oBACJ,OAAO,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;gBAChC,CAAC;gBAAC,OAAO,GAAG,EAAE,CAAC;oBACb,OAAO,SAAS,CAAC,GAAG,EAAG,GAAa,CAAC,OAAO,EAAE,GAAG,CAAC,CAAC;gBACrD,CAAC;YACH,CAAC;YAED,8CAA8C;YAC9C,IAAI,QAAQ,KAAK,kBAAkB,IAAI,MAAM,KAAK,MAAM,EAAE,CAAC;gBACzD,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC;gBAC7C,IAAA,2BAAc,EAAC,IAAI,CAAC,CAAC;gBACrB,OAAO,QAAQ,CAAC,GAAG,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC;YACrC,CAAC;YAED,OAAO,SAAS,CAAC,GAAG,EAAE,WAAW,EAAE,GAAG,CAAC,CAAC;QAC1C,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,OAAO,CAAC,KAAK,CAAC,YAAY,EAAE,GAAG,CAAC,CAAC;YACjC,OAAO,SAAS,CAAC,GAAG,EAAG,GAAa,CAAC,OAAO,CAAC,CAAC;QAChD,CAAC;IACH,CAAC;IAED,yBAAyB;IACzB,MAAM,MAAM,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,CAAC,CAAC;IACxD,IAAI,QAAgB,CAAC;IAErB,6EAA6E;IAC7E,IAAI,QAAQ,KAAK,GAAG,IAAI,QAAQ,KAAK,aAAa,EAAE,CAAC;QACnD,IAAI,IAAI,KAAK,WAAW,EAAE,CAAC;YACzB,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;QACjD,CAAC;aAAM,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;YAC9B,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;QAC/C,CAAC;aAAM,CAAC;YACN,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;QAC7C,CAAC;IACH,CAAC;SAAM,CAAC;QACN,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;IACzC,CAAC;IAED,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IACrC,MAAM,YAAY,GAAG,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC5C,IAAI,CAAC,YAAY,CAAC,UAAU,CAAC,OAAO,CAAC,EAAE,CAAC;QACtC,GAAG,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;QAAC,GAAG,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;QAAC,OAAO;IACnD,CAAC;IAED,IAAI,CAAC;QACH,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,YAAY,CAAC,IAAI,EAAE,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAC,WAAW,EAAE,EAAE,CAAC;YAC5E,GAAG,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;YAAC,GAAG,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;YAAC,OAAO;QACnD,CAAC;QACD,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,CAAC,YAAY,CAAC,CAAC;QACvC,MAAM,WAAW,GAAG,UAAU,CAAC,GAAG,CAAC,IAAI,0BAA0B,CAAC;QAClE,GAAG,CAAC,SAAS,CAAC,GAAG,EAAE,EAAE,cAAc,EAAE,WAAW,EAAE,CAAC,CAAC;QACpD,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,YAAY,CAAC,YAAY,CAAC,CAAC,CAAC;IACzC,CAAC;IAAC,MAAM,CAAC;QACP,GAAG,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;QAAC,GAAG,CAAC,GAAG,CAAC,uBAAuB,CAAC,CAAC;IACvD,CAAC;AACH,CAAC,CAAC,CAAC;AAEH,mBAAmB;AACnB,SAAS,gBAAgB,CAAC,IAAY;IACpC,MAAM,QAAQ,GAAG;QACf,2EAA2E;QAC3E,gDAAgD;QAChD,gEAAgE;KACjE,CAAC;IACF,KAAK,MAAM,CAAC,IAAI,QAAQ,EAAE,CAAC;QAAC,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QAAC,IAAI,CAAC;YAAE,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC;IAAC,CAAC;IAC1E,OAAO,IAAI,CAAC;AACd,CAAC;AAED,SAAS,YAAY,CAAC,IAAY;IAChC,MAAM,KAAK,GAAG,IAAI,CAAC,WAAW,EAAE,CAAC;IACjC,KAAK,MAAM,KAAK,IAAI,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,CAAC;QACpF,IAAI,IAAI,MAAM,CAAC,MAAM,KAAK,KAAK,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC;YAAE,OAAO,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC;IAC5F,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,IAAI,EAAE,GAAG,EAAE;IAC7B,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IAChB,OAAO,CAAC,GAAG,CAAC,wDAAwD,CAAC,CAAC;IACtE,OAAO,CAAC,GAAG,CAAC,8BAA8B,IAAI,CAAC,WAAW,EAAE,CAAC,MAAM,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC;IAC5E,OAAO,CAAC,GAAG,CAAC,wDAAwD,CAAC,CAAC;IACtE,OAAO,CAAC,GAAG,CAAC,2BAA2B,IAAI,IAAI,IAAI,qBAAqB,CAAC,CAAC;IAC1E,OAAO,CAAC,GAAG,CAAC,oBAAoB,MAAM,CAAC,gBAAgB,CAAC,UAAU,EAAE,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,iCAAiC,CAAC,CAAC;IACzH,OAAO,CAAC,GAAG,CAAC,oBAAoB,MAAM,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,iCAAiC,CAAC,CAAC;IACnG,OAAO,CAAC,GAAG,CAAC,oBAAoB,SAAS,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,KAAK,6BAA6B,CAAC,CAAC;IAC3F,OAAO,CAAC,GAAG,CAAC,oBAAoB,OAAO,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,KAAK,6BAA6B,CAAC,CAAC;IACzF,IAAI,oBAAoB,EAAE,CAAC;QACzB,OAAO,CAAC,GAAG,CAAC,oBAAoB,oBAAoB,CAAC,SAAS,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,OAAO,CAAC,CAAC;IAC3F,CAAC;IACD,OAAO,CAAC,GAAG,CAAC,wDAAwD,CAAC,CAAC;IACtE,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IAChB,IAAI,SAAS,EAAE,CAAC;QACd,OAAO,CAAC,GAAG,CAAC,wBAAwB,CAAC,CAAC;QACtC,OAAO,CAAC,GAAG,CAAC,mBAAmB,IAAI,IAAI,IAAI,4DAA4D,CAAC,CAAC;QACzG,OAAO,CAAC,GAAG,CAAC,mBAAmB,IAAI,IAAI,IAAI,oDAAoD,CAAC,CAAC;QACjG,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IAClB,CAAC;AACH,CAAC,CAAC,CAAC"} \ No newline at end of file diff --git a/docs/COLLECTOR.md b/docs/COLLECTOR.md new file mode 100644 index 0000000..24e005e --- /dev/null +++ b/docs/COLLECTOR.md @@ -0,0 +1,358 @@ +# 📡 Log Collector — User Guide + +## Overview + +The Log Collector receives logs from applications via HTTP and UDP, stores them in named streams, rotates log files based on size/time, and optionally forwards them to other systems (databases, other collectors, files). + +--- + +## Starting the Collector + +### Option 1: Environment File +```bash +cp .env.collector .env +node dist-server/server.js +``` + +### Option 2: Inline +```bash +LOG_MODE=collector PORT=8081 COLLECTOR_UDP_PORT=5140 node dist-server/server.js +``` + +### Option 3: Docker +```bash +docker run -d -p 8081:8080 -p 5140:5140/udp \ + -e LOG_MODE=collector \ + -e COLLECTOR_UDP_PORT=5140 \ + -e COLLECTOR_ROTATION_COMPRESSION=gzip \ + -v ./collector-data:/app/collector-data \ + logviewer +``` + +### Option 4: Config File +```json +{ + "mode": "collector", + "port": 8081, + "collector": { + "enabled": true, + "storageDir": "./collector-data", + "maxLinesPerStream": 100000, + "maxStreams": 50, + "persistToDisk": true, + "udp": { "enabled": true, "port": 5140 }, + "rotation": { + "maxSizeBytes": 5242880, + "maxAgeMs": 600000, + "compression": "gzip", + "fileExtension": ".log" + }, + "forwardTargets": [] + } +} +``` + +--- + +## Sending Logs to the Collector + +### HTTP POST (Plain Text) + +Each line in the body becomes one log entry: +```bash +curl -X POST http://localhost:8081/collect/my-app \ + -d "2026-04-30T10:00:01Z INFO Application started" +``` + +Multiple lines: +```bash +curl -X POST http://localhost:8081/collect/my-app -d "line 1 +line 2 +line 3" +``` + +### HTTP POST (JSON) + +Send an array of strings or objects: +```bash +curl -X POST http://localhost:8081/collect/my-app/json \ + -H "Content-Type: application/json" \ + -d '["INFO App started", "ERROR Crash!", {"ts":"2026-04-30","level":"WARN","msg":"low memory"}]' +``` + +### UDP + +Format: `streamId|logline` — the part before `|` is the stream name: +```bash +echo "my-app|2026-04-30 ERROR database timeout" | nc -u localhost 5140 +``` + +Without stream ID (auto-named by sender IP): +```bash +echo "2026-04-30 INFO hello from UDP" | nc -u localhost 5140 +``` + +### From Applications + +**Node.js:** +```javascript +const http = require('http'); +function log(stream, msg) { + const req = http.request({ hostname:'localhost', port:8081, path:`/collect/${stream}`, method:'POST' }); + req.write(`${new Date().toISOString()} ${msg}`); + req.end(); +} +log('my-app', 'INFO Server started on port 3000'); +``` + +**Python:** +```python +import requests, datetime +def log(stream, msg): + ts = datetime.datetime.now().isoformat() + requests.post(f'http://localhost:8081/collect/{stream}', data=f'{ts} {msg}') + +log('my-app', 'INFO Processing complete') +``` + +**Bash (continuous pipe):** +```bash +tail -f /var/log/myapp.log | while read line; do + curl -s -X POST http://localhost:8081/collect/myapp -d "$line" +done +``` + +### Test Script (included) + +Sends random logs every 500ms: +```bash +node send-test-logs.js +node send-test-logs.js demo-app http://localhost:8081 +``` + +--- + +## GUI Guide + +### Layout + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 📡 Log Collector [ACTIVE] Uptime: 5m 32s│ +├──────────────┬──────────────────────────────────────────────┤ +│ │ [🔴 Live Feed] [⚙️ Configuration] [🔀 Fwd] │ +│ 📊 Overview │ │ +│ ┌─────────┐ │ Live Feed: │ +│ │ 3 │ │ [demo-app] 10:00:01 INFO App started │ +│ │ Streams │ │ [demo-app] 10:00:02 ERROR DB timeout ← red │ +│ ├─────────┤ │ [web-srv] 10:00:03 WARN Slow request ← orange +│ │ 1.2k │ │ [demo-app] 10:00:04 INFO Request OK │ +│ │ Lines │ │ │ +│ ├─────────┤ │ New lines appear with animation │ +│ │ 120 │ │ │ +│ │ /min │ │ │ +│ └─────────┘ │ │ +│ │ │ +│ 📡 Streams │ │ +│ ┌──────────┐│ │ +│ │ demo-app ││ │ +│ │ 450 lines││ │ +│ │ 2s ago ││ │ +│ ├──────────┤│ │ +│ │ web-srv ││ │ +│ │ 120 lines││ │ +│ └──────────┘│ │ +│ │ │ +│ 🔗 Endpoints │ +│ POST /collect/ │ +│ UDP :5140 │ │ +└──────────────┴──────────────────────────────────────────────┘ +``` + +### Tabs + +**🔴 Live Feed** +- Shows incoming log lines in real-time +- Color-coded by level (red=ERROR, orange=WARN, blue=INFO) +- New lines animate in from the left +- Click a stream in the sidebar to filter the feed +- Pause/Resume button to freeze the feed +- Clear button to reset + +**⚙️ Configuration** +- Edit all collector settings via the GUI +- Changes are saved to `logviewer.config.json` +- Settings: storage dir, max streams, max lines, UDP port, rotation size/age/compression + +**🔀 Forwarding** +- View active forward targets +- Add new targets (HTTP, File, UDP, Console) +- Configure batch size and flush interval + +--- + +## Configuration Reference + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `LOG_MODE` | `both` | Set to `collector` | +| `PORT` | `8080` | HTTP port | +| `COLLECTOR_UDP_ENABLED` | `true` | Enable/disable UDP receiver | +| `COLLECTOR_UDP_PORT` | `5140` | UDP listen port | +| `COLLECTOR_STORAGE_DIR` | `./collector-data` | Where to store log files | +| `COLLECTOR_MAX_LINES` | `100000` | Max lines kept in memory per stream | +| `COLLECTOR_MAX_STREAMS` | `50` | Max number of streams | +| `COLLECTOR_PERSIST` | `true` | Write logs to disk | +| `COLLECTOR_ROTATION_MAX_SIZE` | `5242880` | Rotate after N bytes (default 5MB) | +| `COLLECTOR_ROTATION_MAX_AGE` | `600000` | Rotate after N ms (default 10min) | +| `COLLECTOR_ROTATION_COMPRESSION` | `gzip` | `none`, `gzip`, `bzip2`, `xz`, `zstd` | +| `COLLECTOR_ROTATION_EXTENSION` | `.log` | File extension for active log | +| `COLLECTOR_FORWARD_TARGETS` | `[]` | JSON array of forward targets | + +### Log Rotation + +Rotation triggers on whichever condition is met **first**: + +| Condition | Variable | Example | +|-----------|----------|---------| +| File size exceeds limit | `COLLECTOR_ROTATION_MAX_SIZE=10485760` | Rotate at 10MB | +| File age exceeds limit | `COLLECTOR_ROTATION_MAX_AGE=3600000` | Rotate every hour | + +Rotated files are named with timestamp and compressed: +``` +collector-data/ +├── my-app.log ← current (active) +├── my-app_2026-04-30T10-00-00-000Z.log.gz ← rotated + gzipped +├── my-app_2026-04-29T22-00-00-000Z.log.gz +└── _streams.json ← metadata +``` + +### Forward Targets + +Forward targets receive copies of all ingested logs. Configure via environment or GUI. + +**HTTP Target** — POST logs to another service: +```json +{"type":"http","url":"http://elasticsearch:9200/_bulk","format":"json","batchSize":200,"flushIntervalMs":5000} +``` + +**File Target** — Append to a file: +```json +{"type":"file","path":"/backup/all-logs.txt"} +``` + +**UDP Target** — Forward via UDP (e.g., to syslog): +```json +{"type":"udp","host":"syslog.internal","port":514} +``` + +**Stream Target** — Forward to another stream on the same collector: +```json +{"type":"stream","targetStream":"all-logs"} +``` +This copies all ingested lines from any stream into the `all-logs` stream. Lines are prefixed with `[fwd:source-stream]` to identify the origin. Useful for creating an aggregated "all logs" stream. + +**Console Target** — Print to stdout (for debugging): +```json +{"type":"console"} +``` + +**Multiple targets:** +```env +COLLECTOR_FORWARD_TARGETS=[{"type":"http","url":"http://elk:9200/_bulk","format":"json","batchSize":100},{"type":"file","path":"/backup/all.log"},{"type":"console"}] +``` + +--- + +## Example Configurations + +### Minimal Collector +```env +LOG_MODE=collector +PORT=8081 +``` + +### Production Collector (large scale) +```env +LOG_MODE=collector +PORT=8081 +COLLECTOR_UDP_PORT=5140 +COLLECTOR_STORAGE_DIR=/data/logs +COLLECTOR_MAX_LINES=500000 +COLLECTOR_MAX_STREAMS=200 +COLLECTOR_ROTATION_MAX_SIZE=52428800 +COLLECTOR_ROTATION_MAX_AGE=3600000 +COLLECTOR_ROTATION_COMPRESSION=zstd +COLLECTOR_FORWARD_TARGETS=[{"type":"http","url":"http://elasticsearch:9200/logs/_bulk","format":"json","batchSize":500,"flushIntervalMs":3000}] +``` + +### Collector with multiple forward targets +```env +LOG_MODE=collector +PORT=8081 +COLLECTOR_FORWARD_TARGETS=[{"type":"http","url":"http://loki:3100/loki/api/v1/push","format":"json","batchSize":100},{"type":"file","path":"/archive/all-logs.txt"},{"type":"udp","host":"siem.internal","port":514}] +``` + +### Collector with stream aggregation (self-forward) +```env +LOG_MODE=collector +PORT=8081 +COLLECTOR_FORWARD_TARGETS=[{"type":"stream","targetStream":"all-logs"},{"type":"console"}] +``` +Every log sent to any stream (e.g., `web-app`, `auth-service`) is also copied into the `all-logs` stream. Lines are prefixed with `[fwd:web-app]` etc. so you can still tell where they came from. + +### Docker Compose: Collector cluster +```yaml +services: + collector-1: + image: logviewer + ports: ["8081:8080", "5140:5140/udp"] + environment: + LOG_MODE: collector + COLLECTOR_UDP_PORT: "5140" + COLLECTOR_ROTATION_COMPRESSION: gzip + COLLECTOR_FORWARD_TARGETS: '[{"type":"http","url":"http://central:8080/collect/region-eu","format":"json"}]' + volumes: + - ./data-eu:/app/collector-data + + collector-2: + image: logviewer + ports: ["8082:8080", "5141:5140/udp"] + environment: + LOG_MODE: collector + COLLECTOR_UDP_PORT: "5140" + COLLECTOR_FORWARD_TARGETS: '[{"type":"http","url":"http://central:8080/collect/region-us","format":"json"}]' + volumes: + - ./data-us:/app/collector-data + + central: + image: logviewer + ports: ["9090:8080"] + environment: + LOG_MODE: both +``` + +--- + +## API Reference + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/collect/:streamId` | Ingest plain text (one line per line) | +| POST | `/collect/:streamId/json` | Ingest JSON array | +| GET | `/collector/streams` | List all streams | +| GET | `/collector/stream/:id?tail=N` | Read stream (optional: last N lines) | +| GET | `/collector/config` | Current collector config | +| DELETE | `/collector/stream/:id` | Delete a stream | + +### Stream Auto-Creation + +Streams are created automatically when you first send logs to them. No pre-configuration needed. Just POST to `/collect/any-name-you-want` and the stream exists. + +### Stream Naming + +Stream IDs support: `a-z`, `A-Z`, `0-9`, `-`, `_`, `.` + +Examples: `my-app`, `web.server.prod`, `auth_service`, `node-1.api` diff --git a/docs/MONITOR.md b/docs/MONITOR.md new file mode 100644 index 0000000..d780b56 --- /dev/null +++ b/docs/MONITOR.md @@ -0,0 +1,377 @@ +# 🔍 Log Monitor — User Guide + +## Overview + +The Log Monitor scans log files on a schedule, matches regex patterns, and sends alerts via Telegram, Webhooks, Microsoft Teams, or Email. It supports time-based filtering, scheduled scans (daily, hourly, continuous), and automatic attachment of full results when matches exceed a threshold. + +--- + +## Starting the Monitor + +### Option 1: Environment File +```bash +cp .env.monitor .env +node dist-server/server.js +``` + +### Option 2: Inline +```bash +LOG_MODE=monitor PORT=8082 MONITOR_SCAN_INTERVAL=30000 node dist-server/server.js +``` + +### Option 3: Docker +```bash +docker run -d -p 8082:8080 \ + -e LOG_MODE=monitor \ + -e MONITOR_SCAN_INTERVAL=30000 \ + -v /var/log:/logs:ro \ + logviewer +``` + +### Option 4: Config File +```json +{ + "mode": "monitor", + "port": 8082, + "monitor": { + "enabled": true, + "scanIntervalMs": 30000, + "rules": [] + } +} +``` + +--- + +## How It Works + +``` +┌─────────────┐ ┌──────────────┐ ┌─────────────────┐ +│ Log Files │────▶│ Scan Engine │────▶│ Pattern Match │ +│ (sources) │ │ (interval) │ │ (regex) │ +└─────────────┘ └──────────────┘ └────────┬────────┘ + │ + matches found? + │ + ┌──────────────┴──────────────┐ + │ │ + ≤ maxLines > maxLines + │ │ + ┌─────▼─────┐ ┌───────▼───────┐ + │ Inline │ │ Inline (N) │ + │ in alert │ │ + .gz attach │ + └─────┬─────┘ └───────┬───────┘ + │ │ + └──────────────┬──────────────┘ + │ + ┌──────────────▼──────────────┐ + │ Send Notifications │ + │ Telegram │ Webhook │ Teams │ + └─────────────────────────────┘ +``` + +1. **Scan** — Every N seconds (configurable), the monitor reads all source files +2. **Filter** — Only files matching the time range are scanned (today, this week, etc.) +3. **Match** — Each line is tested against the configured regex patterns +4. **Alert** — If matches are found AND cooldown has expired, notifications are sent +5. **Attach** — If matches exceed `maxLinesInMessage`, full results are gzipped and attached + +--- + +## GUI Guide + +### Layout + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 🔍 Log Monitor 2 active rules · 3 alerts│ +├──────────────┬──────────────────────────────────────────────┤ +│ │ │ +│ 📋 Rules │ Rule Editor / Alert Detail │ +│ ┌──────────┐│ │ +│ │●Root DB ││ [General] │ +│ │ 2 src ││ Name: Root DB Access Detection │ +│ │ 6 match ││ Enabled: Yes │ +│ ├──────────┤│ │ +│ │●Failed ││ [Sources] │ +│ │ 1 src ││ /var/log/db-audit.log │ +│ │ 4 match ││ /var/log/db-audit-*.log.gz │ +│ └──────────┘│ │ +│ │ [Patterns] │ +│ 📊 Alerts │ root.*login │ +│ ┌──────────┐│ GRANT.*PRIVILEGES │ +│ │🚨 Root DB││ DROP TABLE │ +│ │ 6 matches││ │ +│ │ 10:30:01 ││ [Schedule] │ +│ ├──────────┤│ Mode: Daily at 07:00 │ +│ │🚨 Failed ││ │ +│ │ 4 matches││ [Notifications] │ +│ │ 10:30:01 ││ [TELEGRAM] Bot: 811... Chat: 859... │ +│ └──────────┘│ [WEBHOOK] https://hooks.slack.com/... │ +│ │ │ +│ │ [💾 Save] [🧪 Test Now] [🗑 Delete] │ +└──────────────┴──────────────────────────────────────────────┘ +``` + +### Creating a Rule + +1. Click **"+ New"** in the sidebar +2. Fill in the fields: + - **Name** — descriptive name for the rule + - **Sources** — one file path per line (supports globs like `/var/log/*.log`) + - **Time Range** — which files to scan (today, week, month, last N hours, all) + - **Patterns** — one regex per line + - **Match Logic** — "any" (OR) or "all" (AND) + - **Schedule** — continuous, daily at time, or every N hours + - **Max Lines** — how many lines to include inline in notifications + - **Cooldown** — minimum time between alerts (prevents spam) +3. Add notification channels +4. Click **"💾 Save Rule"** + +### Testing a Rule + +1. Select a rule from the sidebar +2. Click **"🧪 Test Now"** +3. The rule runs immediately and shows results below the form +4. This does NOT respect cooldown — always runs + +### Testing Notifications + +1. In the Notifications section, configure a channel +2. Click **"🧪 Test Notification"** +3. A test message is sent to verify the channel works + +### Viewing Alerts + +- Alerts appear in the **"📊 Recent Alerts"** section of the sidebar +- Click an alert to see the full detail view with all matched lines +- Lines are color-coded: red for ERROR/FATAL, orange for WARN + +--- + +## Configuration Reference + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `LOG_MODE` | `both` | Set to `monitor` | +| `PORT` | `8080` | HTTP port | +| `MONITOR_ENABLED` | `true` | Enable/disable scanning | +| `MONITOR_SCAN_INTERVAL` | `60000` | Scan interval in ms | +| `MONITOR_STORAGE_DIR` | `./monitor-data` | Where to store state | +| `MONITOR_RULES` | `[]` | JSON array of rules (see below) | + +### Rule Structure + +```json +{ + "id": "unique-id", + "name": "Human-readable name", + "enabled": true, + "sources": ["/path/to/logs/*.log"], + "patterns": ["regex1", "regex2"], + "patternLogic": "any", + "timeRange": { "type": "week", "hours": 24 }, + "schedule": { "mode": "daily", "value": "07:00", "timezone": "Europe/Berlin" }, + "notifications": [ + { "type": "telegram", "botToken": "123:ABC", "chatId": "-100123" } + ], + "maxLinesInMessage": 20, + "cooldownMs": 300000, + "matchCount": 0, + "lastTriggered": null +} +``` + +### Time Range Options + +| Type | Description | Use Case | +|------|-------------|----------| +| `today` | Only files modified today | Daily reports | +| `week` | Files from last 7 days | Weekly audits | +| `month` | Files from last 30 days | Monthly reviews | +| `hours` | Files from last N hours | Recent activity | +| `all` | All files regardless of age | Full history scan | + +### Schedule Options + +| Mode | Value | Description | +|------|-------|-------------| +| `interval` | — | Runs every scan interval (continuous monitoring) | +| `daily` | `07:00` | Runs once per day at the specified time | +| `hourly` | `2` | Runs every N hours | + +### Notification Channels + +**Telegram:** +```json +{ "type": "telegram", "botToken": "123456:ABC-DEF...", "chatId": "8599028500" } +``` + +**Webhook (Slack, Discord, custom):** +```json +{ "type": "webhook", "url": "https://hooks.slack.com/services/T.../B.../..." } +``` + +**Microsoft Teams:** +```json +{ "type": "teams", "url": "https://outlook.office.com/webhook/..." } +``` + +**Email (SMTP):** +```json +{ "type": "email", "to": "admin@company.com", "smtpHost": "smtp.gmail.com", "smtpPort": 587, "smtpUser": "alerts@company.com", "smtpPass": "app-password" } +``` + +--- + +## Example Configurations + +### 1. Real-time Root Login Detection + +Scans every 10 seconds, alerts immediately via Telegram: +```env +LOG_MODE=monitor +PORT=8082 +MONITOR_SCAN_INTERVAL=10000 +MONITOR_RULES=[{"id":"root-login","name":"Root Login Alert","enabled":true,"sources":["/var/log/auth.log"],"patterns":["session opened for user root","su.*root","sudo.*root"],"patternLogic":"any","timeRange":{"type":"today"},"schedule":{"mode":"interval"},"notifications":[{"type":"telegram","botToken":"YOUR_BOT_TOKEN","chatId":"YOUR_CHAT_ID"}],"maxLinesInMessage":10,"cooldownMs":60000,"matchCount":0,"lastTriggered":null}] +``` + +### 2. Daily DB Audit Report at 7:00 AM + +Scans once per day, sends full report: +```env +LOG_MODE=monitor +PORT=8082 +MONITOR_SCAN_INTERVAL=60000 +MONITOR_RULES=[{"id":"db-audit","name":"Daily DB Audit","enabled":true,"sources":["/var/log/postgresql/audit.log","/var/log/mysql/audit*.log"],"patterns":["admin.*LOGIN","GRANT","DROP","ALTER.*TABLE","CREATE USER"],"patternLogic":"any","timeRange":{"type":"today"},"schedule":{"mode":"daily","value":"07:00","timezone":"Europe/Berlin"},"notifications":[{"type":"telegram","botToken":"TOKEN","chatId":"CHAT_ID"},{"type":"webhook","url":"https://hooks.slack.com/services/..."}],"maxLinesInMessage":20,"cooldownMs":86400000,"matchCount":0,"lastTriggered":null}] +``` + +### 3. Error Spike Detection (every 5 minutes) + +```env +LOG_MODE=monitor +PORT=8082 +MONITOR_SCAN_INTERVAL=30000 +MONITOR_RULES=[{"id":"error-spike","name":"Error Spike","enabled":true,"sources":["/app/logs/production.log"],"patterns":["\\bERROR\\b","\\bFATAL\\b","Exception","panic:"],"patternLogic":"any","timeRange":{"type":"hours","hours":1},"schedule":{"mode":"hourly","value":"0.083"},"notifications":[{"type":"teams","url":"https://outlook.office.com/webhook/..."}],"maxLinesInMessage":30,"cooldownMs":300000,"matchCount":0,"lastTriggered":null}] +``` + +### 4. Security: Unauthorized Access Attempts + +```env +MONITOR_RULES=[{"id":"unauth","name":"Unauthorized Access","enabled":true,"sources":["/var/log/nginx/access.log","/var/log/nginx/error.log"],"patterns":["403 Forbidden","401 Unauthorized","\\.\\./\\.\\./","etc/passwd"," + + diff --git a/src/renderer/renderer.js b/src/renderer/renderer.js new file mode 100644 index 0000000..5445325 --- /dev/null +++ b/src/renderer/renderer.js @@ -0,0 +1,887 @@ +// ============================================ +// Log Viewer - Renderer +// ============================================ + +(function () { + 'use strict'; + + // State + const state = { + allEntries: [], + filteredEntries: [], + sources: [], + activeSource: 'ALL', + activeLevel: 'ALL', + searchQuery: '', + regexMode: false, + caseSensitive: false, + tailMode: true, + wordWrap: false, + showTimestamps: true, + showLineNumbers: true, + showSourceColumn: true, + bookmarks: new Set(), + zoomLevel: 100, + theme: 'dark', + refreshInterval: 2000, + refreshTimer: null, + isLoading: false, + contextMenu: null, + }; + + // DOM Elements + const $ = (sel) => document.querySelector(sel); + const $$ = (sel) => document.querySelectorAll(sel); + + const elements = { + logOutput: $('#log-output'), + searchInput: $('#search-input'), + searchCount: $('#search-count'), + levelFilter: $('#level-filter'), + sourceFilter: $('#source-filter'), + sourceList: $('#source-list'), + emptySources: $('#empty-sources'), + welcomeMessage: $('#welcome-message'), + statLines: $('#stat-lines'), + statFiltered: $('#stat-filtered'), + statusMessage: $('#status-message'), + statusTail: $('#status-tail'), + statusWrap: $('#status-wrap'), + statusRegex: $('#status-regex'), + statusZoom: $('#status-zoom'), + currentSourceName: $('#current-source-name'), + currentSourceInfo: $('#current-source-info'), + themeIcon: $('#theme-icon'), + sidebar: $('#sidebar'), + shortcutsModal: $('#shortcuts-modal'), + exportModal: $('#export-modal'), + gotoModal: $('#goto-modal'), + gotoInput: $('#goto-input'), + }; + + // ============================================ + // Initialization + // ============================================ + async function init() { + try { + const config = await window.logViewerAPI.getConfig(); + state.theme = config.theme || 'dark'; + state.tailMode = config.tailMode !== false; + state.refreshInterval = config.refreshInterval || 2000; + state.activeLevel = config.defaultLevel || 'ALL'; + + applyTheme(); + elements.levelFilter.value = state.activeLevel; + + await loadSources(); + if (state.sources.length > 0) { + await loadAllLogs(); + } + + setupEventListeners(); + setupIPCListeners(); + startAutoRefresh(); + updateStatusBar(); + } catch (err) { + console.error('Init error:', err); + setStatus('Error initializing: ' + err.message); + } + } + + // ============================================ + // Source Management + // ============================================ + async function loadSources() { + state.sources = await window.logViewerAPI.getSources(); + renderSourceList(); + updateSourceFilter(); + } + + function renderSourceList() { + const list = elements.sourceList; + + if (state.sources.length === 0) { + elements.emptySources.style.display = 'block'; + // Remove all source items but keep empty state + list.querySelectorAll('.source-item').forEach((el) => el.remove()); + return; + } + + elements.emptySources.style.display = 'none'; + list.querySelectorAll('.source-item').forEach((el) => el.remove()); + + for (const source of state.sources) { + const item = document.createElement('div'); + item.className = 'source-item' + (state.activeSource === source.path ? ' active' : ''); + item.setAttribute('role', 'listitem'); + item.setAttribute('tabindex', '0'); + + const icon = getSourceIcon(source.type); + const size = formatFileSize(source.size); + const modified = source.lastModified ? formatRelativeTime(new Date(source.lastModified)) : 'unknown'; + + item.innerHTML = ` + + ${icon} +
+
${source.displayName}
+
${size} · ${modified}
+
+ + `; + + item.addEventListener('click', (e) => { + if (e.target.closest('.source-remove')) return; + selectSource(source.path); + }); + + item.querySelector('.source-remove').addEventListener('click', async (e) => { + e.stopPropagation(); + await window.logViewerAPI.removeSource(source.path); + await loadSources(); + if (state.activeSource === source.path) { + state.activeSource = 'ALL'; + await loadAllLogs(); + } + }); + + item.addEventListener('contextmenu', (e) => { + e.preventDefault(); + showContextMenu(e, [ + { label: '📂 Reveal in Explorer', action: () => window.logViewerAPI.revealInExplorer(source.path) }, + { label: '🔄 Reload', action: () => loadLogForSource(source.path) }, + { separator: true }, + { label: '❌ Remove', action: async () => { + await window.logViewerAPI.removeSource(source.path); + await loadSources(); + }}, + ]); + }); + + list.appendChild(item); + } + } + + function updateSourceFilter() { + const select = elements.sourceFilter; + const currentValue = select.value; + + // Remove all options except "All Sources" + while (select.options.length > 1) { + select.remove(1); + } + + for (const source of state.sources) { + const option = document.createElement('option'); + option.value = source.path; + option.textContent = source.displayName; + select.appendChild(option); + } + + select.value = currentValue || 'ALL'; + } + + async function selectSource(sourcePath) { + state.activeSource = sourcePath; + + if (sourcePath === 'ALL') { + elements.currentSourceName.textContent = 'All Sources'; + elements.currentSourceInfo.textContent = ''; + await loadAllLogs(); + } else { + const source = state.sources.find((s) => s.path === sourcePath); + elements.currentSourceName.textContent = source ? source.displayName : sourcePath; + elements.currentSourceInfo.textContent = source ? `${formatFileSize(source.size)} · ${source.type}` : ''; + await loadLogForSource(sourcePath); + } + + elements.sourceFilter.value = sourcePath; + renderSourceList(); + } + + // ============================================ + // Log Loading + // ============================================ + async function loadAllLogs() { + if (state.isLoading) return; + state.isLoading = true; + setStatus('Loading logs...'); + + try { + state.allEntries = await window.logViewerAPI.readAllLogs(); + applyFilters(); + setStatus(`Loaded ${state.allEntries.length} lines from ${state.sources.length} source(s)`); + } catch (err) { + setStatus('Error loading logs: ' + err.message); + } finally { + state.isLoading = false; + } + } + + async function loadLogForSource(sourcePath) { + if (state.isLoading) return; + state.isLoading = true; + setStatus('Loading...'); + + try { + state.allEntries = await window.logViewerAPI.readLog(sourcePath); + applyFilters(); + setStatus(`Loaded ${state.allEntries.length} lines from ${sourcePath.split(/[/\\]/).pop()}`); + } catch (err) { + setStatus('Error: ' + err.message); + } finally { + state.isLoading = false; + } + } + + // ============================================ + // Filtering & Search + // ============================================ + function applyFilters() { + let entries = [...state.allEntries]; + + // Level filter + if (state.activeLevel !== 'ALL') { + entries = entries.filter((e) => e.level === state.activeLevel); + } + + // Source filter (when viewing all) + if (state.activeSource !== 'ALL') { + entries = entries.filter((e) => e.source === state.activeSource); + } + + // Search filter + if (state.searchQuery) { + const query = state.searchQuery; + let matcher; + + if (state.regexMode) { + try { + const flags = state.caseSensitive ? 'g' : 'gi'; + matcher = new RegExp(query, flags); + } catch { + elements.searchInput.style.borderColor = 'var(--error)'; + elements.searchCount.textContent = 'Invalid regex'; + state.filteredEntries = entries; + renderLogs(); + return; + } + } + + elements.searchInput.style.borderColor = ''; + + entries = entries.filter((e) => { + if (state.regexMode) { + matcher.lastIndex = 0; + return matcher.test(e.line); + } + const line = state.caseSensitive ? e.line : e.line.toLowerCase(); + const q = state.caseSensitive ? query : query.toLowerCase(); + return line.includes(q); + }); + + elements.searchCount.textContent = `${entries.length} match${entries.length !== 1 ? 'es' : ''}`; + } else { + elements.searchCount.textContent = ''; + elements.searchInput.style.borderColor = ''; + } + + state.filteredEntries = entries; + renderLogs(); + updateStats(); + } + + // ============================================ + // Rendering + // ============================================ + function renderLogs() { + const output = elements.logOutput; + const wasAtBottom = isScrolledToBottom(); + + // Hide welcome message + if (elements.welcomeMessage) { + elements.welcomeMessage.style.display = state.allEntries.length > 0 ? 'none' : 'flex'; + } + + // Use document fragment for performance + const fragment = document.createDocumentFragment(); + + for (const entry of state.filteredEntries) { + const line = createLogLine(entry); + fragment.appendChild(line); + } + + // Clear and append + const existingLines = output.querySelectorAll('.log-line'); + existingLines.forEach((el) => el.remove()); + output.appendChild(fragment); + + // Auto-scroll if tail mode + if (state.tailMode && wasAtBottom) { + scrollToBottom(); + } + } + + function createLogLine(entry) { + const div = document.createElement('div'); + div.className = 'log-line'; + div.dataset.lineNumber = entry.lineNumber; + div.dataset.source = entry.source; + + // Level-based styling + if (entry.level) { + const levelLower = entry.level.toLowerCase(); + if (levelLower === 'error' || levelLower === 'fatal') { + div.classList.add('line-error'); + } else if (levelLower === 'fatal') { + div.classList.add('line-fatal'); + } else if (levelLower === 'warn' || levelLower === 'warning') { + div.classList.add('line-warn'); + } + } + + // Bookmark + if (state.bookmarks.has(`${entry.source}:${entry.lineNumber}`)) { + div.classList.add('bookmarked'); + } + + // Line number + if (state.showLineNumbers) { + const lineNum = document.createElement('span'); + lineNum.className = 'log-line-number'; + lineNum.textContent = entry.lineNumber; + lineNum.title = 'Click to bookmark'; + lineNum.addEventListener('click', () => toggleBookmark(entry)); + div.appendChild(lineNum); + } + + // Source column (when viewing all sources) + if (state.showSourceColumn && state.activeSource === 'ALL') { + const sourceCol = document.createElement('span'); + sourceCol.className = 'log-line-source'; + sourceCol.textContent = entry.source.split(/[/\\]/).pop(); + sourceCol.title = entry.source; + div.appendChild(sourceCol); + } + + // Timestamp + if (state.showTimestamps && entry.timestamp) { + const ts = document.createElement('span'); + ts.className = 'log-line-timestamp'; + ts.textContent = entry.timestamp; + div.appendChild(ts); + } + + // Level badge + if (entry.level) { + const level = document.createElement('span'); + level.className = `log-line-level level-${entry.level.toLowerCase()}`; + level.textContent = entry.level; + div.appendChild(level); + } + + // Content + const content = document.createElement('span'); + content.className = 'log-line-content'; + + if (state.searchQuery) { + content.innerHTML = highlightSearch(escapeHtml(entry.line)); + } else { + content.textContent = entry.line; + } + + div.appendChild(content); + + // Context menu + div.addEventListener('contextmenu', (e) => { + e.preventDefault(); + showContextMenu(e, [ + { label: '📋 Copy Line', action: () => navigator.clipboard.writeText(entry.line) }, + { label: '🔖 Toggle Bookmark', action: () => toggleBookmark(entry) }, + { separator: true }, + { label: '🔍 Filter by this level', action: () => { + if (entry.level) { + state.activeLevel = entry.level; + elements.levelFilter.value = entry.level; + applyFilters(); + } + }}, + { label: '📂 Filter by this source', action: () => selectSource(entry.source) }, + { separator: true }, + { label: '📋 Copy all visible', action: () => { + const text = state.filteredEntries.map((e) => e.line).join('\n'); + navigator.clipboard.writeText(text); + }}, + ]); + }); + + return div; + } + + function highlightSearch(htmlText) { + if (!state.searchQuery) return htmlText; + + try { + let pattern; + if (state.regexMode) { + pattern = state.searchQuery; + } else { + pattern = state.searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + const flags = state.caseSensitive ? 'g' : 'gi'; + const regex = new RegExp(`(${pattern})`, flags); + return htmlText.replace(regex, '$1'); + } catch { + return htmlText; + } + } + + // ============================================ + // Bookmarks + // ============================================ + function toggleBookmark(entry) { + const key = `${entry.source}:${entry.lineNumber}`; + if (state.bookmarks.has(key)) { + state.bookmarks.delete(key); + } else { + state.bookmarks.add(key); + } + renderLogs(); + } + + function clearBookmarks() { + state.bookmarks.clear(); + renderLogs(); + } + + // ============================================ + // Context Menu + // ============================================ + function showContextMenu(event, items) { + hideContextMenu(); + + const menu = document.createElement('div'); + menu.className = 'context-menu'; + + for (const item of items) { + if (item.separator) { + const sep = document.createElement('div'); + sep.className = 'context-menu-separator'; + menu.appendChild(sep); + } else { + const menuItem = document.createElement('div'); + menuItem.className = 'context-menu-item'; + menuItem.textContent = item.label; + menuItem.addEventListener('click', () => { + item.action(); + hideContextMenu(); + }); + menu.appendChild(menuItem); + } + } + + document.body.appendChild(menu); + + // Position + const x = Math.min(event.clientX, window.innerWidth - menu.offsetWidth - 10); + const y = Math.min(event.clientY, window.innerHeight - menu.offsetHeight - 10); + menu.style.left = x + 'px'; + menu.style.top = y + 'px'; + + state.contextMenu = menu; + + // Close on click outside + setTimeout(() => { + document.addEventListener('click', hideContextMenu, { once: true }); + }, 0); + } + + function hideContextMenu() { + if (state.contextMenu) { + state.contextMenu.remove(); + state.contextMenu = null; + } + } + + // ============================================ + // Auto Refresh + // ============================================ + function startAutoRefresh() { + if (state.refreshTimer) clearInterval(state.refreshTimer); + state.refreshTimer = setInterval(async () => { + if (state.tailMode && !state.isLoading) { + if (state.activeSource === 'ALL') { + await loadAllLogs(); + } else { + await loadLogForSource(state.activeSource); + } + } + }, state.refreshInterval); + } + + // ============================================ + // Theme + // ============================================ + function toggleTheme() { + state.theme = state.theme === 'dark' ? 'light' : 'dark'; + applyTheme(); + } + + function applyTheme() { + document.body.className = `theme-${state.theme}`; + elements.themeIcon.textContent = state.theme === 'dark' ? '🌙' : '☀️'; + } + + // ============================================ + // Zoom + // ============================================ + function zoomIn() { + state.zoomLevel = Math.min(state.zoomLevel + 10, 200); + applyZoom(); + } + + function zoomOut() { + state.zoomLevel = Math.max(state.zoomLevel - 10, 50); + applyZoom(); + } + + function zoomReset() { + state.zoomLevel = 100; + applyZoom(); + } + + function applyZoom() { + elements.logOutput.style.fontSize = (12 * state.zoomLevel / 100) + 'px'; + elements.statusZoom.textContent = state.zoomLevel + '%'; + } + + // ============================================ + // Helpers + // ============================================ + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + function isScrolledToBottom() { + const el = elements.logOutput; + return el.scrollHeight - el.scrollTop - el.clientHeight < 50; + } + + function scrollToBottom() { + elements.logOutput.scrollTop = elements.logOutput.scrollHeight; + } + + function scrollToTop() { + elements.logOutput.scrollTop = 0; + } + + function setStatus(message) { + elements.statusMessage.textContent = message; + } + + function updateStats() { + elements.statLines.textContent = `${state.allEntries.length} lines`; + elements.statFiltered.textContent = `${state.filteredEntries.length} shown`; + } + + function updateStatusBar() { + elements.statusTail.classList.toggle('active', state.tailMode); + elements.statusWrap.classList.toggle('active', state.wordWrap); + elements.statusRegex.classList.toggle('active', state.regexMode); + } + + function getSourceIcon(type) { + switch (type) { + case 'gzip': return '📦'; + case 'bzip2': return '📦'; + case 'xz': return '📦'; + case 'zstd': return '📦'; + default: return '📄'; + } + } + + function formatFileSize(bytes) { + if (bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i]; + } + + function formatRelativeTime(date) { + const now = new Date(); + const diff = now - date; + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (seconds < 60) return 'just now'; + if (minutes < 60) return `${minutes}m ago`; + if (hours < 24) return `${hours}h ago`; + return `${days}d ago`; + } + + function showModal(modal) { + modal.hidden = false; + modal.querySelector('input, button')?.focus(); + } + + function hideModal(modal) { + modal.hidden = true; + } + + // ============================================ + // Event Listeners + // ============================================ + function setupEventListeners() { + // Toolbar buttons + $('#btn-add-source').addEventListener('click', async () => { + const sources = await window.logViewerAPI.openFileDialog(); + if (sources) { + await loadSources(); + await loadAllLogs(); + } + }); + + $('#btn-reload').addEventListener('click', async () => { + await loadSources(); + if (state.activeSource === 'ALL') { + await loadAllLogs(); + } else { + await loadLogForSource(state.activeSource); + } + }); + + $('#btn-tail').addEventListener('click', () => { + state.tailMode = !state.tailMode; + $('#btn-tail').classList.toggle('active', state.tailMode); + $('#btn-tail').setAttribute('aria-pressed', state.tailMode); + updateStatusBar(); + if (state.tailMode) scrollToBottom(); + }); + + $('#btn-wrap').addEventListener('click', () => { + state.wordWrap = !state.wordWrap; + $('#btn-wrap').classList.toggle('active', state.wordWrap); + $('#btn-wrap').setAttribute('aria-pressed', state.wordWrap); + elements.logOutput.classList.toggle('word-wrap', state.wordWrap); + updateStatusBar(); + }); + + $('#btn-timestamps').addEventListener('click', () => { + state.showTimestamps = !state.showTimestamps; + $('#btn-timestamps').classList.toggle('active', state.showTimestamps); + $('#btn-timestamps').setAttribute('aria-pressed', state.showTimestamps); + renderLogs(); + }); + + // Search + elements.searchInput.addEventListener('input', () => { + state.searchQuery = elements.searchInput.value; + applyFilters(); + }); + + $('#btn-regex-toggle').addEventListener('click', () => { + state.regexMode = !state.regexMode; + $('#btn-regex-toggle').classList.toggle('active', state.regexMode); + $('#btn-regex-toggle').setAttribute('aria-pressed', state.regexMode); + updateStatusBar(); + if (state.searchQuery) applyFilters(); + }); + + $('#btn-case-toggle').addEventListener('click', () => { + state.caseSensitive = !state.caseSensitive; + $('#btn-case-toggle').classList.toggle('active', state.caseSensitive); + $('#btn-case-toggle').setAttribute('aria-pressed', state.caseSensitive); + if (state.searchQuery) applyFilters(); + }); + + // Filters + elements.levelFilter.addEventListener('change', () => { + state.activeLevel = elements.levelFilter.value; + applyFilters(); + }); + + elements.sourceFilter.addEventListener('change', () => { + selectSource(elements.sourceFilter.value); + }); + + // Theme + $('#btn-theme').addEventListener('click', toggleTheme); + + // Export + $('#btn-export').addEventListener('click', () => showModal(elements.exportModal)); + $('#close-export').addEventListener('click', () => hideModal(elements.exportModal)); + + $('#export-txt').addEventListener('click', async () => { + const filteredOnly = $('#export-filtered-only').checked; + const entries = filteredOnly ? state.filteredEntries : state.allEntries; + const lines = entries.map((e) => e.line); + await window.logViewerAPI.exportLogs(lines, 'txt'); + hideModal(elements.exportModal); + }); + + $('#export-json').addEventListener('click', async () => { + const filteredOnly = $('#export-filtered-only').checked; + const entries = filteredOnly ? state.filteredEntries : state.allEntries; + const lines = entries.map((e) => e.line); + await window.logViewerAPI.exportLogs(lines, 'json'); + hideModal(elements.exportModal); + }); + + // Sidebar + $('#btn-toggle-sidebar').addEventListener('click', () => { + elements.sidebar.classList.toggle('collapsed'); + const btn = $('#btn-toggle-sidebar'); + btn.textContent = elements.sidebar.classList.contains('collapsed') ? '▶' : '◀'; + }); + + // Scroll buttons + $('#btn-scroll-top').addEventListener('click', scrollToTop); + $('#btn-scroll-bottom').addEventListener('click', scrollToBottom); + $('#btn-clear-bookmarks').addEventListener('click', clearBookmarks); + + // Shortcuts modal + $('#close-shortcuts').addEventListener('click', () => hideModal(elements.shortcutsModal)); + + // Go to line + $('#close-goto').addEventListener('click', () => hideModal(elements.gotoModal)); + $('#goto-btn').addEventListener('click', () => { + const lineNum = parseInt(elements.gotoInput.value, 10); + if (lineNum > 0) { + const target = elements.logOutput.querySelector(`[data-line-number="${lineNum}"]`); + if (target) { + target.scrollIntoView({ behavior: 'smooth', block: 'center' }); + target.style.background = 'var(--highlight-bg)'; + setTimeout(() => { target.style.background = ''; }, 2000); + } + hideModal(elements.gotoModal); + } + }); + + elements.gotoInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') $('#goto-btn').click(); + }); + + // Modal overlay click to close + $$('.modal-overlay').forEach((overlay) => { + overlay.addEventListener('click', (e) => { + if (e.target === overlay) hideModal(overlay); + }); + }); + + // Keyboard shortcuts + document.addEventListener('keydown', (e) => { + // Escape - close modals / clear search + if (e.key === 'Escape') { + $$('.modal-overlay').forEach((m) => hideModal(m)); + if (state.searchQuery) { + state.searchQuery = ''; + elements.searchInput.value = ''; + applyFilters(); + } + hideContextMenu(); + return; + } + + // Ctrl+G - Go to line + if (e.ctrlKey && e.key === 'g') { + e.preventDefault(); + showModal(elements.gotoModal); + elements.gotoInput.value = ''; + elements.gotoInput.focus(); + return; + } + + // Ctrl+B - Bookmark current + if (e.ctrlKey && e.key === 'b') { + e.preventDefault(); + // Bookmark is handled via line number click + return; + } + + // Home/End for scroll + if (e.key === 'Home' && !e.target.matches('input')) { + e.preventDefault(); + scrollToTop(); + } + if (e.key === 'End' && !e.target.matches('input')) { + e.preventDefault(); + scrollToBottom(); + } + }); + + // Disable tail mode on manual scroll up + elements.logOutput.addEventListener('scroll', () => { + if (!isScrolledToBottom() && state.tailMode) { + // Don't disable tail mode, just note we're not at bottom + } + }); + } + + // ============================================ + // IPC Listeners (from main process) + // ============================================ + function setupIPCListeners() { + window.logViewerAPI.onSourcesUpdated(async (sources) => { + state.sources = sources; + renderSourceList(); + updateSourceFilter(); + await loadAllLogs(); + }); + + window.logViewerAPI.onReloadSources(async () => { + await loadSources(); + if (state.activeSource === 'ALL') { + await loadAllLogs(); + } else { + await loadLogForSource(state.activeSource); + } + }); + + window.logViewerAPI.onToggleSearch(() => { + elements.searchInput.focus(); + elements.searchInput.select(); + }); + + window.logViewerAPI.onToggleRegexSearch(() => { + state.regexMode = true; + $('#btn-regex-toggle').classList.add('active'); + updateStatusBar(); + elements.searchInput.focus(); + elements.searchInput.select(); + }); + + window.logViewerAPI.onToggleTail(() => { + state.tailMode = !state.tailMode; + $('#btn-tail').classList.toggle('active', state.tailMode); + updateStatusBar(); + if (state.tailMode) scrollToBottom(); + }); + + window.logViewerAPI.onToggleWrap(() => { + state.wordWrap = !state.wordWrap; + $('#btn-wrap').classList.toggle('active', state.wordWrap); + elements.logOutput.classList.toggle('word-wrap', state.wordWrap); + updateStatusBar(); + }); + + window.logViewerAPI.onToggleTheme(toggleTheme); + + window.logViewerAPI.onZoomIn(zoomIn); + window.logViewerAPI.onZoomOut(zoomOut); + window.logViewerAPI.onZoomReset(zoomReset); + + window.logViewerAPI.onShowShortcuts(() => { + showModal(elements.shortcutsModal); + }); + } + + // ============================================ + // Start + // ============================================ + init(); +})(); diff --git a/src/renderer/styles.css b/src/renderer/styles.css new file mode 100644 index 0000000..789d5a4 --- /dev/null +++ b/src/renderer/styles.css @@ -0,0 +1,963 @@ +/* ============================================ + Log Viewer - Styles + ============================================ */ + +:root { + /* Dark Theme (default) */ + --bg-primary: #1e1e2e; + --bg-secondary: #181825; + --bg-tertiary: #313244; + --bg-hover: #45475a; + --bg-active: #585b70; + --text-primary: #cdd6f4; + --text-secondary: #a6adc8; + --text-muted: #6c7086; + --border-color: #45475a; + --accent: #89b4fa; + --accent-hover: #74c7ec; + --error: #f38ba8; + --warn: #fab387; + --info: #89b4fa; + --debug: #a6adc8; + --fatal: #f38ba8; + --trace: #6c7086; + --success: #a6e3a1; + --highlight-bg: #f9e2af33; + --highlight-border: #f9e2af; + --bookmark-color: #f9e2af; + --scrollbar-bg: #313244; + --scrollbar-thumb: #585b70; + --shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + --font-mono: 'Cascadia Code', 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; + --font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +.theme-light { + --bg-primary: #eff1f5; + --bg-secondary: #e6e9ef; + --bg-tertiary: #ccd0da; + --bg-hover: #bcc0cc; + --bg-active: #acb0be; + --text-primary: #4c4f69; + --text-secondary: #5c5f77; + --text-muted: #8c8fa1; + --border-color: #ccd0da; + --accent: #1e66f5; + --accent-hover: #2a6ef5; + --error: #d20f39; + --warn: #df8e1d; + --info: #1e66f5; + --debug: #8c8fa1; + --fatal: #d20f39; + --trace: #8c8fa1; + --success: #40a02b; + --highlight-bg: #df8e1d33; + --highlight-border: #df8e1d; + --bookmark-color: #df8e1d; + --scrollbar-bg: #ccd0da; + --scrollbar-thumb: #acb0be; + --shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +/* ============================================ + Reset & Base + ============================================ */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + height: 100%; + overflow: hidden; +} + +body { + font-family: var(--font-ui); + font-size: 13px; + color: var(--text-primary); + background: var(--bg-primary); + display: flex; + flex-direction: column; + transition: background-color 0.2s, color 0.2s; +} + +/* ============================================ + Scrollbar + ============================================ */ +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: var(--scrollbar-bg); +} + +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border-radius: 5px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--bg-active); +} + +/* ============================================ + Toolbar + ============================================ */ +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 12px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + gap: 12px; + flex-shrink: 0; + -webkit-app-region: drag; + user-select: none; +} + +.toolbar-left, .toolbar-center, .toolbar-right { + display: flex; + align-items: center; + gap: 6px; + -webkit-app-region: no-drag; +} + +.toolbar-center { + flex: 1; + max-width: 500px; +} + +.toolbar-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 5px 10px; + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: pointer; + font-size: 12px; + font-family: var(--font-ui); + transition: all 0.15s; + white-space: nowrap; +} + +.toolbar-btn:hover { + background: var(--bg-hover); +} + +.toolbar-btn:active { + background: var(--bg-active); +} + +.toolbar-btn.active { + background: var(--accent); + color: #1e1e2e; + border-color: var(--accent); +} + +.toolbar-separator { + width: 1px; + height: 24px; + background: var(--border-color); + margin: 0 4px; +} + +.toolbar-select { + padding: 5px 8px; + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 12px; + font-family: var(--font-ui); + cursor: pointer; + max-width: 160px; +} + +.toolbar-select:focus { + outline: 2px solid var(--accent); + outline-offset: -1px; +} + +/* ============================================ + Search Box + ============================================ */ +.search-box { + display: flex; + align-items: center; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 4px; + overflow: hidden; + width: 100%; + transition: border-color 0.15s; +} + +.search-box:focus-within { + border-color: var(--accent); +} + +#search-input { + flex: 1; + padding: 5px 10px; + background: transparent; + color: var(--text-primary); + border: none; + font-size: 12px; + font-family: var(--font-ui); + outline: none; + min-width: 0; +} + +#search-input::placeholder { + color: var(--text-muted); +} + +.search-toggle { + padding: 4px 8px; + background: transparent; + color: var(--text-muted); + border: none; + border-left: 1px solid var(--border-color); + cursor: pointer; + font-size: 11px; + font-family: var(--font-mono); + font-weight: bold; + transition: all 0.15s; +} + +.search-toggle:hover { + color: var(--text-primary); + background: var(--bg-hover); +} + +.search-toggle.active { + color: var(--accent); + background: var(--bg-hover); +} + +.search-count { + padding: 0 8px; + font-size: 11px; + color: var(--text-muted); + white-space: nowrap; +} + +/* ============================================ + Main Content + ============================================ */ +.main-content { + display: flex; + flex: 1; + overflow: hidden; +} + +/* ============================================ + Sidebar + ============================================ */ +.sidebar { + width: 260px; + min-width: 200px; + background: var(--bg-secondary); + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + transition: width 0.2s, min-width 0.2s; + flex-shrink: 0; +} + +.sidebar.collapsed { + width: 0; + min-width: 0; + overflow: hidden; + border-right: none; +} + +.sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border-bottom: 1px solid var(--border-color); +} + +.sidebar-header h2 { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.sidebar-toggle { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 12px; + padding: 2px 6px; + border-radius: 3px; + transition: all 0.15s; +} + +.sidebar-toggle:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.source-list { + flex: 1; + overflow-y: auto; + padding: 6px; +} + +.source-item { + display: flex; + align-items: center; + padding: 8px 10px; + border-radius: 4px; + cursor: pointer; + transition: background 0.15s; + gap: 8px; + margin-bottom: 2px; +} + +.source-item:hover { + background: var(--bg-hover); +} + +.source-item.active { + background: var(--bg-tertiary); + border-left: 3px solid var(--accent); + padding-left: 7px; +} + +.source-item .source-icon { + font-size: 14px; + flex-shrink: 0; +} + +.source-item .source-details { + flex: 1; + min-width: 0; +} + +.source-item .source-name { + font-size: 12px; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.source-item .source-meta { + font-size: 10px; + color: var(--text-muted); + margin-top: 2px; +} + +.source-item .source-remove { + opacity: 0; + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 14px; + padding: 2px 4px; + border-radius: 3px; + transition: all 0.15s; +} + +.source-item:hover .source-remove { + opacity: 1; +} + +.source-item .source-remove:hover { + color: var(--error); + background: var(--bg-hover); +} + +.source-item .source-status { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.source-item .source-status.online { + background: var(--success); +} + +.source-item .source-status.offline { + background: var(--error); +} + +.empty-state { + padding: 20px; + text-align: center; + color: var(--text-muted); + font-size: 12px; + line-height: 1.6; +} + +.empty-state code { + background: var(--bg-tertiary); + padding: 2px 6px; + border-radius: 3px; + font-family: var(--font-mono); + font-size: 11px; +} + +.sidebar-footer { + padding: 8px 12px; + border-top: 1px solid var(--border-color); +} + +.stats { + display: flex; + justify-content: space-between; + font-size: 11px; + color: var(--text-muted); +} + +/* ============================================ + Log Container + ============================================ */ +.log-container { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.log-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 12px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; +} + +.log-header-info { + display: flex; + align-items: center; + gap: 10px; +} + +#current-source-name { + font-weight: 600; + font-size: 13px; +} + +.source-info { + font-size: 11px; + color: var(--text-muted); +} + +.log-header-actions { + display: flex; + gap: 6px; +} + +.small-btn { + padding: 3px 8px; + background: var(--bg-tertiary); + color: var(--text-secondary); + border: 1px solid var(--border-color); + border-radius: 3px; + cursor: pointer; + font-size: 11px; + font-family: var(--font-ui); + transition: all 0.15s; +} + +.small-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +/* ============================================ + Log Output + ============================================ */ +.log-output { + flex: 1; + overflow-y: auto; + overflow-x: auto; + padding: 0; + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.6; + background: var(--bg-primary); + outline: none; +} + +.log-output.word-wrap { + overflow-x: hidden; +} + +.log-output.word-wrap .log-line-content { + white-space: pre-wrap; + word-break: break-all; +} + +.log-line { + display: flex; + padding: 0 12px; + min-height: 20px; + border-bottom: 1px solid transparent; + transition: background 0.1s; +} + +.log-line:hover { + background: var(--bg-tertiary); +} + +.log-line.bookmarked { + background: var(--highlight-bg); + border-left: 3px solid var(--bookmark-color); +} + +.log-line.search-match { + background: rgba(137, 180, 250, 0.1); +} + +.log-line.search-current { + background: rgba(137, 180, 250, 0.25); + border-left: 3px solid var(--accent); +} + +.log-line-number { + min-width: 50px; + padding-right: 12px; + text-align: right; + color: var(--text-muted); + user-select: none; + flex-shrink: 0; + cursor: pointer; + font-size: 11px; + line-height: 20px; +} + +.log-line-number:hover { + color: var(--accent); +} + +.log-line-source { + min-width: 80px; + max-width: 120px; + padding-right: 8px; + color: var(--text-muted); + font-size: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex-shrink: 0; + line-height: 20px; +} + +.log-line-timestamp { + min-width: 160px; + padding-right: 8px; + color: var(--text-secondary); + font-size: 11px; + flex-shrink: 0; + line-height: 20px; +} + +.log-line-level { + min-width: 60px; + padding-right: 8px; + font-weight: 600; + font-size: 11px; + flex-shrink: 0; + line-height: 20px; +} + +.log-line-level.level-error, +.log-line-level.level-fatal { + color: var(--error); +} + +.log-line-level.level-warn { + color: var(--warn); +} + +.log-line-level.level-info { + color: var(--info); +} + +.log-line-level.level-debug { + color: var(--debug); +} + +.log-line-level.level-trace { + color: var(--trace); +} + +.log-line-content { + flex: 1; + white-space: pre; + min-width: 0; + line-height: 20px; +} + +.log-line-content .highlight { + background: var(--highlight-bg); + border-bottom: 1px solid var(--highlight-border); + border-radius: 2px; + padding: 0 1px; +} + +/* Level-based line coloring */ +.log-line.line-error { + color: var(--error); +} + +.log-line.line-fatal { + color: var(--fatal); + font-weight: 600; +} + +.log-line.line-warn { + color: var(--warn); +} + +/* ============================================ + Welcome Message + ============================================ */ +.welcome-message { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-muted); + text-align: center; + padding: 40px; +} + +.welcome-message h2 { + font-size: 20px; + color: var(--text-secondary); + margin-bottom: 16px; +} + +.welcome-message p { + margin-bottom: 8px; +} + +.welcome-message ul { + list-style: none; + margin: 12px 0; +} + +.welcome-message li { + padding: 4px 0; +} + +.welcome-message code { + background: var(--bg-tertiary); + padding: 2px 6px; + border-radius: 3px; + font-family: var(--font-mono); + font-size: 12px; +} + +.shortcut-hint { + margin-top: 20px; + padding: 12px 20px; + background: var(--bg-tertiary); + border-radius: 6px; +} + +kbd { + display: inline-block; + padding: 2px 6px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 3px; + font-family: var(--font-mono); + font-size: 11px; + box-shadow: 0 1px 0 var(--border-color); +} + +/* ============================================ + Status Bar + ============================================ */ +.statusbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 3px 12px; + background: var(--bg-secondary); + border-top: 1px solid var(--border-color); + font-size: 11px; + color: var(--text-muted); + flex-shrink: 0; +} + +.statusbar-left, .statusbar-right { + display: flex; + align-items: center; + gap: 12px; +} + +.status-indicator { + padding: 1px 6px; + border-radius: 3px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + opacity: 0.4; + transition: opacity 0.15s; +} + +.status-indicator.active { + opacity: 1; + color: var(--accent); +} + +/* ============================================ + Modals + ============================================ */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal-overlay[hidden] { + display: none; +} + +.modal { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: var(--shadow); + min-width: 400px; + max-width: 600px; + max-height: 80vh; + overflow-y: auto; +} + +.modal-small { + min-width: 300px; + max-width: 400px; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border-color); +} + +.modal-header h2 { + font-size: 16px; + font-weight: 600; +} + +.modal-close { + background: none; + border: none; + color: var(--text-muted); + font-size: 20px; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + transition: all 0.15s; +} + +.modal-close:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.modal-body { + padding: 20px; +} + +.shortcut-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +.shortcut-group h3 { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.shortcut-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 0; + font-size: 12px; +} + +.shortcut-item kbd { + margin-right: 12px; +} + +.export-options { + display: flex; + gap: 10px; + margin: 16px 0; +} + +.export-btn { + flex: 1; + padding: 10px; + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 6px; + cursor: pointer; + font-size: 13px; + font-family: var(--font-ui); + transition: all 0.15s; +} + +.export-btn:hover { + background: var(--accent); + color: #1e1e2e; + border-color: var(--accent); +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-secondary); + cursor: pointer; +} + +.primary-btn { + padding: 6px 16px; + background: var(--accent); + color: #1e1e2e; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 13px; + font-family: var(--font-ui); + font-weight: 600; + transition: all 0.15s; +} + +.primary-btn:hover { + background: var(--accent-hover); +} + +#goto-input { + width: 100%; + padding: 8px 12px; + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 14px; + font-family: var(--font-mono); + margin-bottom: 12px; + outline: none; +} + +#goto-input:focus { + border-color: var(--accent); +} + +/* ============================================ + Loading & Animations + ============================================ */ +.loading-spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid var(--border-color); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ============================================ + Context Menu + ============================================ */ +.context-menu { + position: fixed; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + box-shadow: var(--shadow); + padding: 4px; + z-index: 2000; + min-width: 180px; +} + +.context-menu-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + color: var(--text-primary); + font-size: 12px; + cursor: pointer; + border-radius: 4px; + transition: background 0.1s; +} + +.context-menu-item:hover { + background: var(--bg-hover); +} + +.context-menu-separator { + height: 1px; + background: var(--border-color); + margin: 4px 0; +} + +/* ============================================ + Responsive + ============================================ */ +@media (max-width: 900px) { + .sidebar { + width: 200px; + min-width: 160px; + } + + .toolbar-center { + max-width: 300px; + } + + .shortcut-grid { + grid-template-columns: 1fr; + } +} diff --git a/src/server/collector.ts b/src/server/collector.ts new file mode 100644 index 0000000..f59d94c --- /dev/null +++ b/src/server/collector.ts @@ -0,0 +1,508 @@ +import * as http from 'http'; +import * as dgram from 'dgram'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as zlib from 'zlib'; + +export interface RotationConfig { + maxSizeBytes: number; // Rotate after N bytes (0 = disabled) + maxAgeMs: number; // Rotate after N ms (0 = disabled) + compression: 'none' | 'gzip' | 'bzip2' | 'xz' | 'zstd'; + fileExtension: string; // .log, .txt, etc. +} + +export interface ForwardTarget { + type: 'http' | 'file' | 'udp' | 'console' | 'stream'; + url?: string; // For http targets + path?: string; // For file targets + host?: string; // For udp targets + port?: number; // For udp targets + targetStream?: string; // For stream targets (self-forward to another stream) + format?: 'json' | 'text'; // Output format + batchSize?: number; // Lines to batch before sending + flushIntervalMs?: number; // Max time before flushing +} + +export interface LogStream { + id: string; + name: string; + description?: string; + createdAt: string; + lineCount: number; + lastReceived: string | null; + currentFileSize: number; +} + +export interface CollectorConfig { + storageDir: string; + maxLinesPerStream: number; + maxStreams: number; + udpPort: number; + udpEnabled: boolean; + rotation: RotationConfig; + forwardTargets: ForwardTarget[]; + persistToDisk: boolean; +} + +export class LogCollector { + private config: CollectorConfig; + private streams: Map = new Map(); + private buffers: Map = new Map(); + private fileSizes: Map = new Map(); + private fileCreatedAt: Map = new Map(); + private rotationTimer: NodeJS.Timeout | null = null; + private udpServer: dgram.Socket | null = null; + private forwardBuffers: Map = new Map(); + private forwardTimers: Map = new Map(); + + constructor(config: Partial = {}) { + this.config = { + storageDir: config.storageDir || path.join(process.cwd(), 'collector-data'), + maxLinesPerStream: config.maxLinesPerStream || 100000, + maxStreams: config.maxStreams || 50, + udpPort: config.udpPort || 5140, + udpEnabled: config.udpEnabled !== false, + rotation: config.rotation || { + maxSizeBytes: 5 * 1024 * 1024, // 5MB default + maxAgeMs: 10 * 60 * 1000, // 10 min default + compression: 'gzip', + fileExtension: '.log', + }, + forwardTargets: config.forwardTargets || [], + persistToDisk: config.persistToDisk !== false, + }; + + if (!fs.existsSync(this.config.storageDir)) { + fs.mkdirSync(this.config.storageDir, { recursive: true }); + } + + this.loadExistingStreams(); + + // Start rotation check timer + if (this.config.rotation.maxAgeMs > 0) { + this.rotationTimer = setInterval(() => this.checkTimeRotation(), Math.min(this.config.rotation.maxAgeMs / 2, 30000)); + } + + // Start UDP server + if (this.config.udpEnabled) { + this.startUdpServer(); + } + } + + private startUdpServer(): void { + try { + this.udpServer = dgram.createSocket('udp4'); + + this.udpServer.on('message', (msg, rinfo) => { + const message = msg.toString('utf-8').trim(); + if (!message) return; + + // Parse stream ID from message or use sender IP as stream + // Format: | or just (uses IP:port as stream) + let streamId: string; + let logLine: string; + + const pipeIdx = message.indexOf('|'); + if (pipeIdx > 0 && pipeIdx < 64 && !message.substring(0, pipeIdx).includes(' ')) { + streamId = message.substring(0, pipeIdx); + logLine = message.substring(pipeIdx + 1); + } else { + streamId = `udp-${rinfo.address}`; + logLine = message; + } + + this.ingest(streamId, [logLine], `UDP ${rinfo.address}:${rinfo.port}`); + }); + + this.udpServer.on('error', (err) => { + console.error(`UDP server error: ${err.message}`); + }); + + this.udpServer.bind(this.config.udpPort, '0.0.0.0', () => { + console.log(` UDP collector listening on port ${this.config.udpPort}`); + }); + } catch (err) { + console.error('Failed to start UDP server:', (err as Error).message); + } + } + + private loadExistingStreams(): void { + try { + const metaFile = path.join(this.config.storageDir, '_streams.json'); + if (fs.existsSync(metaFile)) { + const data = JSON.parse(fs.readFileSync(metaFile, 'utf-8')); + for (const stream of data) { + this.streams.set(stream.id, stream); + const streamFile = this.getStreamFilePath(stream.id); + if (fs.existsSync(streamFile)) { + const content = fs.readFileSync(streamFile, 'utf-8'); + const lines = content.split('\n').filter(l => l.length > 0); + this.buffers.set(stream.id, lines); + const stat = fs.statSync(streamFile); + this.fileSizes.set(stream.id, stat.size); + this.fileCreatedAt.set(stream.id, stat.birthtimeMs || Date.now()); + } else { + this.buffers.set(stream.id, []); + this.fileSizes.set(stream.id, 0); + this.fileCreatedAt.set(stream.id, Date.now()); + } + } + } + } catch (err) { + console.error('Error loading collector streams:', err); + } + } + + private saveMetadata(): void { + const metaFile = path.join(this.config.storageDir, '_streams.json'); + fs.writeFileSync(metaFile, JSON.stringify(Array.from(this.streams.values()), null, 2)); + } + + private getStreamFilePath(streamId: string): string { + return path.join(this.config.storageDir, `${streamId}${this.config.rotation.fileExtension}`); + } + + private getRotatedFilePath(streamId: string): string { + const ts = new Date().toISOString().replace(/[:.]/g, '-'); + const ext = this.config.rotation.fileExtension; + const compExt = this.getCompressionExtension(); + return path.join(this.config.storageDir, `${streamId}_${ts}${ext}${compExt}`); + } + + private getCompressionExtension(): string { + switch (this.config.rotation.compression) { + case 'gzip': return '.gz'; + case 'bzip2': return '.bz2'; + case 'xz': return '.xz'; + case 'zstd': return '.zst'; + default: return ''; + } + } + + private saveStream(streamId: string): void { + if (!this.config.persistToDisk) return; + const lines = this.buffers.get(streamId) || []; + const streamFile = this.getStreamFilePath(streamId); + const content = lines.join('\n') + (lines.length > 0 ? '\n' : ''); + fs.writeFileSync(streamFile, content); + this.fileSizes.set(streamId, Buffer.byteLength(content, 'utf-8')); + } + + private checkSizeRotation(streamId: string): void { + if (this.config.rotation.maxSizeBytes <= 0) return; + const size = this.fileSizes.get(streamId) || 0; + if (size >= this.config.rotation.maxSizeBytes) { + this.rotateStream(streamId); + } + } + + private checkTimeRotation(): void { + if (this.config.rotation.maxAgeMs <= 0) return; + const now = Date.now(); + for (const [streamId, createdAt] of this.fileCreatedAt) { + if (now - createdAt >= this.config.rotation.maxAgeMs) { + const buffer = this.buffers.get(streamId); + if (buffer && buffer.length > 0) { + this.rotateStream(streamId); + } + } + } + } + + private rotateStream(streamId: string): void { + const srcFile = this.getStreamFilePath(streamId); + if (!fs.existsSync(srcFile)) return; + + const content = fs.readFileSync(srcFile); + if (content.length === 0) return; + + const destFile = this.getRotatedFilePath(streamId); + + // Compress and write + let compressed: Buffer; + switch (this.config.rotation.compression) { + case 'gzip': + compressed = zlib.gzipSync(content); + break; + case 'bzip2': + case 'xz': + case 'zstd': + // For these, just copy without compression (would need external tools) + compressed = content; + break; + default: + compressed = content; + } + + fs.writeFileSync(destFile, compressed); + console.log(` Rotated: ${streamId} -> ${path.basename(destFile)} (${compressed.length} bytes)`); + + // Reset current file + fs.writeFileSync(srcFile, ''); + this.buffers.set(streamId, []); + this.fileSizes.set(streamId, 0); + this.fileCreatedAt.set(streamId, Date.now()); + + const stream = this.streams.get(streamId); + if (stream) { stream.lineCount = 0; stream.currentFileSize = 0; } + this.saveMetadata(); + } + + // ---- Forwarding ---- + private forwardLines(streamId: string, lines: string[]): void { + for (const target of this.config.forwardTargets) { + const key = `${streamId}:${target.type}:${target.url || target.path || target.host}`; + if (!this.forwardBuffers.has(key)) this.forwardBuffers.set(key, []); + const buf = this.forwardBuffers.get(key)!; + buf.push(...lines); + + const batchSize = target.batchSize || 100; + if (buf.length >= batchSize) { + this.flushForward(key, target, streamId); + } else if (!this.forwardTimers.has(key)) { + const flushMs = target.flushIntervalMs || 5000; + this.forwardTimers.set(key, setTimeout(() => { + this.flushForward(key, target, streamId); + this.forwardTimers.delete(key); + }, flushMs)); + } + } + } + + private flushForward(key: string, target: ForwardTarget, streamId: string): void { + const lines = this.forwardBuffers.get(key) || []; + if (lines.length === 0) return; + this.forwardBuffers.set(key, []); + + switch (target.type) { + case 'http': + this.forwardHttp(target, streamId, lines); + break; + case 'file': + this.forwardFile(target, lines); + break; + case 'udp': + this.forwardUdp(target, streamId, lines); + break; + case 'stream': + this.forwardToStream(target, streamId, lines); + break; + case 'console': + for (const l of lines) console.log(`[FWD:${streamId}] ${l}`); + break; + } + } + + private forwardHttp(target: ForwardTarget, streamId: string, lines: string[]): void { + if (!target.url) return; + const payload = target.format === 'json' + ? JSON.stringify({ stream: streamId, lines, timestamp: new Date().toISOString() }) + : lines.join('\n'); + + try { + const url = new URL(target.url); + const transport = url.protocol === 'https:' ? require('https') : require('http'); + const req = transport.request({ + hostname: url.hostname, + port: url.port || (url.protocol === 'https:' ? 443 : 80), + path: url.pathname, + method: 'POST', + headers: { 'Content-Type': target.format === 'json' ? 'application/json' : 'text/plain', 'X-Stream-Id': streamId }, + }); + req.on('error', (e: Error) => console.error(`Forward HTTP error: ${e.message}`)); + req.write(payload); + req.end(); + } catch (e) { console.error(`Forward HTTP error: ${(e as Error).message}`); } + } + + private forwardFile(target: ForwardTarget, lines: string[]): void { + if (!target.path) return; + try { + fs.appendFileSync(target.path, lines.join('\n') + '\n'); + } catch (e) { console.error(`Forward file error: ${(e as Error).message}`); } + } + + private forwardUdp(target: ForwardTarget, streamId: string, lines: string[]): void { + if (!target.host || !target.port) return; + try { + const client = dgram.createSocket('udp4'); + for (const line of lines) { + const msg = Buffer.from(`${streamId}|${line}`); + client.send(msg, target.port, target.host); + } + setTimeout(() => client.close(), 1000); + } catch (e) { console.error(`Forward UDP error: ${(e as Error).message}`); } + } + + private forwardToStream(target: ForwardTarget, sourceStreamId: string, lines: string[]): void { + const targetId = target.targetStream; + if (!targetId || targetId === sourceStreamId) return; // Prevent infinite loop + try { + // Prefix lines with source stream info + const prefixed = lines.map(l => `[fwd:${sourceStreamId}] ${l}`); + // Ingest directly into the target stream without triggering forwards again + const stream = this.getOrCreateStream(targetId, `Forwarded from ${sourceStreamId}`); + const buffer = this.buffers.get(targetId)!; + for (const line of prefixed) { + if (buffer.length >= this.config.maxLinesPerStream) buffer.shift(); + buffer.push(line); + } + stream.lineCount = buffer.length; + stream.lastReceived = new Date().toISOString(); + if (this.config.persistToDisk) this.saveStream(targetId); + this.saveMetadata(); + } catch (e) { console.error(`Forward stream error: ${(e as Error).message}`); } + } + + // ---- Public API ---- + + getOrCreateStream(streamId: string, name?: string): LogStream { + if (this.streams.has(streamId)) return this.streams.get(streamId)!; + if (this.streams.size >= this.config.maxStreams) throw new Error(`Max streams (${this.config.maxStreams}) reached`); + + const stream: LogStream = { + id: streamId, name: name || streamId, createdAt: new Date().toISOString(), + lineCount: 0, lastReceived: null, currentFileSize: 0, + }; + this.streams.set(streamId, stream); + this.buffers.set(streamId, []); + this.fileSizes.set(streamId, 0); + this.fileCreatedAt.set(streamId, Date.now()); + this.saveMetadata(); + return stream; + } + + ingest(streamId: string, lines: string[], streamName?: string): { accepted: number; dropped: number } { + const stream = this.getOrCreateStream(streamId, streamName); + const buffer = this.buffers.get(streamId)!; + let accepted = 0, dropped = 0; + + const validLines: string[] = []; + for (const line of lines) { + if (line.trim() === '') continue; + if (buffer.length >= this.config.maxLinesPerStream) { buffer.shift(); dropped++; } + buffer.push(line); + validLines.push(line); + accepted++; + } + + stream.lineCount = buffer.length; + stream.lastReceived = new Date().toISOString(); + + if (this.config.persistToDisk) { + this.saveStream(streamId); + stream.currentFileSize = this.fileSizes.get(streamId) || 0; + this.checkSizeRotation(streamId); + } + + // Forward to targets + if (validLines.length > 0 && this.config.forwardTargets.length > 0) { + this.forwardLines(streamId, validLines); + } + + this.saveMetadata(); + return { accepted, dropped }; + } + + getStreams(): LogStream[] { return Array.from(this.streams.values()); } + + readStream(streamId: string, tail?: number): string[] { + const buffer = this.buffers.get(streamId); + if (!buffer) throw new Error(`Stream not found: ${streamId}`); + return tail && tail > 0 ? buffer.slice(-tail) : [...buffer]; + } + + deleteStream(streamId: string): boolean { + if (!this.streams.has(streamId)) return false; + this.streams.delete(streamId); + this.buffers.delete(streamId); + this.fileSizes.delete(streamId); + this.fileCreatedAt.delete(streamId); + const f = this.getStreamFilePath(streamId); + if (fs.existsSync(f)) fs.unlinkSync(f); + this.saveMetadata(); + return true; + } + + getConfig(): CollectorConfig { return { ...this.config }; } + + shutdown(): void { + if (this.rotationTimer) clearInterval(this.rotationTimer); + if (this.udpServer) this.udpServer.close(); + for (const timer of this.forwardTimers.values()) clearTimeout(timer); + } + + handleRequest(req: http.IncomingMessage, res: http.ServerResponse, pathname: string): boolean { + const method = req.method || 'GET'; + + // POST /collect/:streamId + const collectMatch = pathname.match(/^\/collect\/([a-zA-Z0-9_\-\.]+)(\/json)?$/); + if (collectMatch && method === 'POST') { + const streamId = collectMatch[1]; + const isJson = !!collectMatch[2]; + let body = ''; + req.on('data', (chunk) => { body += chunk.toString(); }); + req.on('end', () => { + try { + let lines: string[]; + if (isJson) { + const parsed = JSON.parse(body); + lines = Array.isArray(parsed) ? parsed.map((e: any) => typeof e === 'string' ? e : JSON.stringify(e)) : [JSON.stringify(parsed)]; + } else { + lines = body.split('\n'); + } + const streamName = req.headers['x-stream-name'] as string | undefined; + const result = this.ingest(streamId, lines, streamName); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, ...result })); + } catch (err) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (err as Error).message })); + } + }); + return true; + } + + // GET /collector/streams + if (pathname === '/collector/streams' && method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(this.getStreams())); + return true; + } + + // GET /collector/config + if (pathname === '/collector/config' && method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(this.getConfig())); + return true; + } + + // GET /collector/stream/:id + const readMatch = pathname.match(/^\/collector\/stream\/([a-zA-Z0-9_\-\.]+)$/); + if (readMatch && method === 'GET') { + const streamId = readMatch[1]; + try { + const url = new URL(req.url || '/', `http://${req.headers.host}`); + const tail = url.searchParams.get('tail'); + const lines = this.readStream(streamId, tail ? parseInt(tail, 10) : undefined); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ streamId, lineCount: lines.length, lines })); + } catch (err) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (err as Error).message })); + } + return true; + } + + // DELETE /collector/stream/:id + const deleteMatch = pathname.match(/^\/collector\/stream\/([a-zA-Z0-9_\-\.]+)$/); + if (deleteMatch && method === 'DELETE') { + const deleted = this.deleteStream(deleteMatch[1]); + res.writeHead(deleted ? 200 : 404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: deleted })); + return true; + } + + return false; + } +} diff --git a/src/server/configFile.ts b/src/server/configFile.ts new file mode 100644 index 0000000..dcc5ba8 --- /dev/null +++ b/src/server/configFile.ts @@ -0,0 +1,93 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export interface LogViewerConfig { + mode: string; + port: number; + host: string; + viewer: { + sources: string[]; + theme: string; + refreshInterval: number; + tailMode: boolean; + maxLines: number; + defaultLevel: string; + }; + collector: { + enabled: boolean; + storageDir: string; + maxLinesPerStream: number; + maxStreams: number; + persistToDisk: boolean; + udp: { enabled: boolean; port: number }; + rotation: { maxSizeBytes: number; maxAgeMs: number; compression: string; fileExtension: string }; + forwardTargets: any[]; + }; + s3: { sources: Record }; + remoteCollector: { url: string }; +} + +const CONFIG_FILE = 'logviewer.config.json'; + +export function getConfigFilePath(): string { + return path.join(process.cwd(), CONFIG_FILE); +} + +export function loadConfigFile(): LogViewerConfig { + const defaults: LogViewerConfig = { + mode: 'both', + port: 8080, + host: '0.0.0.0', + viewer: { + sources: [], + theme: 'dark', + refreshInterval: 2000, + tailMode: true, + maxLines: 50000, + defaultLevel: 'ALL', + }, + collector: { + enabled: true, + storageDir: './collector-data', + maxLinesPerStream: 100000, + maxStreams: 50, + persistToDisk: true, + udp: { enabled: true, port: 5140 }, + rotation: { maxSizeBytes: 5242880, maxAgeMs: 600000, compression: 'gzip', fileExtension: '.log' }, + forwardTargets: [], + }, + s3: { sources: {} }, + remoteCollector: { url: '' }, + }; + + const filePath = getConfigFilePath(); + if (fs.existsSync(filePath)) { + try { + const fileContent = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + return deepMerge(defaults, fileContent); + } catch (err) { + console.error(`Error reading ${CONFIG_FILE}:`, (err as Error).message); + } + } + + return defaults; +} + +export function saveConfigFile(config: Partial): void { + const filePath = getConfigFilePath(); + const existing = loadConfigFile(); + const merged = deepMerge(existing, config); + fs.writeFileSync(filePath, JSON.stringify(merged, null, 2)); +} + +function deepMerge(target: any, source: any): any { + const result = { ...target }; + for (const key of Object.keys(source)) { + if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { + result[key] = deepMerge(target[key] || {}, source[key]); + } else { + result[key] = source[key]; + } + } + return result; +} diff --git a/src/server/configManager.ts b/src/server/configManager.ts new file mode 100644 index 0000000..b072593 --- /dev/null +++ b/src/server/configManager.ts @@ -0,0 +1,79 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export interface AppConfig { + sources: string[]; + refreshInterval: number; + maxLines: number; + theme: 'dark' | 'light'; + defaultLevel: string; + tailMode: boolean; + timestampRegex: string | null; +} + +export class ConfigManager { + private config: AppConfig; + + constructor() { + this.config = this.loadConfig(); + } + + private loadConfig(): AppConfig { + this.loadEnvFile(); + + const sourcesRaw = process.env.LOG_SOURCES || ''; + const sources = sourcesRaw + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + + return { + sources, + refreshInterval: parseInt(process.env.LOG_REFRESH_INTERVAL || '2000', 10), + maxLines: parseInt(process.env.LOG_MAX_LINES || '50000', 10), + theme: (process.env.LOG_THEME as 'dark' | 'light') || 'dark', + defaultLevel: process.env.LOG_DEFAULT_LEVEL || 'ALL', + tailMode: process.env.LOG_TAIL_MODE !== 'false', + timestampRegex: process.env.LOG_TIMESTAMP_REGEX || null, + }; + } + + private loadEnvFile(): void { + const envPaths = [ + path.join(process.cwd(), '.env'), + path.join(__dirname, '..', '..', '.env'), + ]; + + for (const envPath of envPaths) { + if (fs.existsSync(envPath)) { + const content = fs.readFileSync(envPath, 'utf-8'); + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith('#')) { + const eqIndex = trimmed.indexOf('='); + if (eqIndex > 0) { + const key = trimmed.substring(0, eqIndex).trim(); + const value = trimmed.substring(eqIndex + 1).trim(); + if (!process.env[key]) { + process.env[key] = value; + } + } + } + } + break; + } + } + } + + getConfig(): AppConfig { + return { ...this.config }; + } + + updateSources(sources: string[]): void { + this.config.sources = sources; + } + + getSources(): string[] { + return [...this.config.sources]; + } +} diff --git a/src/server/logSourceManager.ts b/src/server/logSourceManager.ts new file mode 100644 index 0000000..0588c3d --- /dev/null +++ b/src/server/logSourceManager.ts @@ -0,0 +1,274 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as zlib from 'zlib'; +import { execSync } from 'child_process'; +import { ConfigManager } from './configManager'; + +export interface LogEntry { + line: string; + lineNumber: number; + source: string; + timestamp: string | null; + level: string | null; +} + +export interface LogSource { + path: string; + displayName: string; + type: 'text' | 'gzip' | 'bzip2' | 'xz' | 'zstd' | 'unknown'; + size: number; + lastModified: string | null; + exists: boolean; +} + +export interface FileStats { + size: number; + lastModified: string; + lineCount: number; + type: string; +} + +export class LogSourceManager { + private configManager: ConfigManager; + private sources: Map = new Map(); + + constructor(configManager: ConfigManager) { + this.configManager = configManager; + this.initializeSources(); + } + + private initializeSources(): void { + const configSources = this.configManager.getSources(); + for (const source of configSources) { + this.addSource(source); + } + } + + addSource(sourcePath: string): void { + if (sourcePath.includes('*')) { + const resolvedPaths = this.resolveGlob(sourcePath); + for (const rp of resolvedPaths) { + this.addSingleSource(rp); + } + } else { + this.addSingleSource(sourcePath); + } + this.configManager.updateSources(Array.from(this.sources.keys())); + } + + private addSingleSource(sourcePath: string): void { + const resolved = path.resolve(sourcePath); + const type = this.detectType(resolved); + let size = 0; + let lastModified: string | null = null; + let exists = false; + + try { + const stats = fs.statSync(resolved); + size = stats.size; + lastModified = stats.mtime.toISOString(); + exists = true; + } catch { + // File doesn't exist yet + } + + this.sources.set(resolved, { + path: resolved, + displayName: path.basename(resolved), + type, + size, + lastModified, + exists, + }); + } + + removeSource(sourcePath: string): void { + const resolved = path.resolve(sourcePath); + this.sources.delete(resolved); + this.configManager.updateSources(Array.from(this.sources.keys())); + } + + getSources(): LogSource[] { + for (const [key, source] of this.sources) { + try { + const stats = fs.statSync(key); + source.size = stats.size; + source.lastModified = stats.mtime.toISOString(); + source.exists = true; + } catch { + source.exists = false; + } + } + return Array.from(this.sources.values()); + } + + private detectType(filePath: string): LogSource['type'] { + const ext = path.extname(filePath).toLowerCase(); + switch (ext) { + case '.gz': case '.gzip': return 'gzip'; + case '.bz2': case '.bzip2': return 'bzip2'; + case '.xz': return 'xz'; + case '.zst': case '.zstd': return 'zstd'; + default: return 'text'; + } + } + + async readLog(sourcePath: string): Promise { + const resolved = path.resolve(sourcePath); + const source = this.sources.get(resolved); + if (!source) throw new Error(`Source not found: ${sourcePath}`); + + const config = this.configManager.getConfig(); + const content = this.readFileContent(resolved, source.type); + const lines = content.split('\n'); + const maxLines = config.maxLines; + const startIndex = lines.length > maxLines ? lines.length - maxLines : 0; + const entries: LogEntry[] = []; + + for (let i = startIndex; i < lines.length; i++) { + const line = lines[i]; + if (line.trim() === '') continue; + entries.push({ + line, + lineNumber: i + 1, + source: resolved, + timestamp: this.extractTimestamp(line, config.timestampRegex), + level: this.extractLevel(line), + }); + } + return entries; + } + + async readAllLogs(): Promise { + const allEntries: LogEntry[] = []; + for (const [sourcePath] of this.sources) { + try { + const entries = await this.readLog(sourcePath); + allEntries.push(...entries); + } catch (err) { + allEntries.push({ + line: `[ERROR] Could not read ${sourcePath}: ${(err as Error).message}`, + lineNumber: 0, + source: sourcePath, + timestamp: new Date().toISOString(), + level: 'ERROR', + }); + } + } + allEntries.sort((a, b) => { + if (a.timestamp && b.timestamp) return a.timestamp.localeCompare(b.timestamp); + return 0; + }); + return allEntries; + } + + private readFileContent(filePath: string, type: LogSource['type']): string { + switch (type) { + case 'gzip': return this.readGzip(filePath); + case 'bzip2': return this.readBzip2(filePath); + case 'xz': return this.readXz(filePath); + case 'zstd': return this.readZstd(filePath); + default: return fs.readFileSync(filePath, 'utf-8'); + } + } + + private readGzip(filePath: string): string { + const compressed = fs.readFileSync(filePath); + return zlib.gunzipSync(compressed).toString('utf-8'); + } + + private readBzip2(filePath: string): string { + try { + return execSync(`bzcat "${filePath}"`, { maxBuffer: 100 * 1024 * 1024 }).toString('utf-8'); + } catch { + throw new Error('Cannot decompress bzip2. Make sure bzip2 is installed.'); + } + } + + private readXz(filePath: string): string { + try { + return execSync(`xzcat "${filePath}"`, { maxBuffer: 100 * 1024 * 1024 }).toString('utf-8'); + } catch { + throw new Error('Cannot decompress xz. Make sure xz-utils is installed.'); + } + } + + private readZstd(filePath: string): string { + try { + return execSync(`zstdcat "${filePath}"`, { maxBuffer: 100 * 1024 * 1024 }).toString('utf-8'); + } catch { + throw new Error('Cannot decompress zstd. Make sure zstd is installed.'); + } + } + + private extractTimestamp(line: string, customRegex: string | null): string | null { + const patterns = [ + /(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)/, + /^([A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})/, + /\[(\d{2}\/[A-Z][a-z]{2}\/\d{4}:\d{2}:\d{2}:\d{2}\s[+-]\d{4})\]/, + /(\d{4}\/\d{2}\/\d{2}\s+\d{2}:\d{2}:\d{2})/, + ]; + + if (customRegex) { + try { + const match = line.match(new RegExp(customRegex)); + if (match) return match[1] || match[0]; + } catch { /* skip */ } + } + + for (const pattern of patterns) { + const match = line.match(pattern); + if (match) return match[1]; + } + return null; + } + + private extractLevel(line: string): string | null { + const upper = line.toUpperCase(); + const levels = ['FATAL', 'ERROR', 'WARN', 'WARNING', 'INFO', 'DEBUG', 'TRACE', 'VERBOSE']; + for (const level of levels) { + if (new RegExp(`\\b${level}\\b`).test(upper)) { + return level === 'WARNING' ? 'WARN' : level; + } + } + return null; + } + + private resolveGlob(pattern: string): string[] { + const dir = path.dirname(pattern); + const filePattern = path.basename(pattern); + const results: string[] = []; + try { + const files = fs.readdirSync(dir); + const regex = new RegExp( + '^' + filePattern.replace(/\./g, '\\.').replace(/\*/g, '.*').replace(/\?/g, '.') + '$' + ); + for (const file of files) { + if (regex.test(file)) { + const fullPath = path.join(dir, file); + try { + if (fs.statSync(fullPath).isFile()) results.push(fullPath); + } catch { /* skip */ } + } + } + } catch { /* dir doesn't exist */ } + return results; + } + + async getFileStats(sourcePath: string): Promise { + const resolved = path.resolve(sourcePath); + try { + const stats = fs.statSync(resolved); + const source = this.sources.get(resolved); + const content = this.readFileContent(resolved, source?.type || 'text'); + return { + size: stats.size, + lastModified: stats.mtime.toISOString(), + lineCount: content.split('\n').length, + type: source?.type || 'text', + }; + } catch { + return null; + } + } +} diff --git a/src/server/monitor.ts b/src/server/monitor.ts new file mode 100644 index 0000000..1dd223f --- /dev/null +++ b/src/server/monitor.ts @@ -0,0 +1,539 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as zlib from 'zlib'; +import * as http from 'http'; +import * as https from 'https'; +import * as url from 'url'; + +// ---- Types ---- + +export interface MonitorRule { + id: string; + name: string; + enabled: boolean; + sources: string[]; // File paths or globs + patterns: string[]; // Regex patterns to match + patternLogic: 'any' | 'all'; // Match any or all patterns + timeRange: TimeRange; + notifications: NotificationChannel[]; + maxLinesInMessage: number; // Max lines to include inline (rest zipped) + cooldownMs: number; // Min time between alerts for same rule + lastTriggered: string | null; + matchCount: number; +} + +export interface TimeRange { + type: 'today' | 'week' | 'month' | 'hours' | 'all'; + hours?: number; // For type 'hours' +} + +export interface NotificationChannel { + type: 'webhook' | 'telegram' | 'email' | 'teams'; + url?: string; // For webhook, teams + chatId?: string; // For telegram + botToken?: string; // For telegram + to?: string; // For email + smtpHost?: string; + smtpPort?: number; + smtpUser?: string; + smtpPass?: string; +} + +export interface MonitorResult { + ruleId: string; + ruleName: string; + matchedLines: string[]; + totalMatches: number; + sources: string[]; + timestamp: string; +} + +export interface MonitorConfig { + enabled: boolean; + scanIntervalMs: number; // How often to scan (default 60s) + rules: MonitorRule[]; + storageDir: string; +} + +// ---- Monitor Engine ---- + +export class LogMonitor { + private config: MonitorConfig; + private scanTimer: NodeJS.Timeout | null = null; + private results: MonitorResult[] = []; + + constructor(config: Partial = {}) { + this.config = { + enabled: config.enabled !== false, + scanIntervalMs: config.scanIntervalMs || 60000, + rules: config.rules || [], + storageDir: config.storageDir || path.join(process.cwd(), 'monitor-data'), + }; + + if (!fs.existsSync(this.config.storageDir)) { + fs.mkdirSync(this.config.storageDir, { recursive: true }); + } + + this.loadState(); + + if (this.config.enabled && this.config.rules.length > 0) { + this.startScanning(); + } + } + + private loadState(): void { + const stateFile = path.join(this.config.storageDir, '_monitor_state.json'); + if (fs.existsSync(stateFile)) { + try { + const data = JSON.parse(fs.readFileSync(stateFile, 'utf-8')); + this.results = data.results || []; + // Restore lastTriggered from state + if (data.rules) { + for (const saved of data.rules) { + const rule = this.config.rules.find(r => r.id === saved.id); + if (rule) { + rule.lastTriggered = saved.lastTriggered; + rule.matchCount = saved.matchCount || 0; + } + } + } + } catch { } + } + } + + private saveState(): void { + const stateFile = path.join(this.config.storageDir, '_monitor_state.json'); + fs.writeFileSync(stateFile, JSON.stringify({ + results: this.results.slice(-100), + rules: this.config.rules.map(r => ({ id: r.id, lastTriggered: r.lastTriggered, matchCount: r.matchCount })), + }, null, 2)); + } + + startScanning(): void { + if (this.scanTimer) clearInterval(this.scanTimer); + console.log(` Monitor scanning every ${this.config.scanIntervalMs / 1000}s with ${this.config.rules.length} rule(s)`); + this.scanTimer = setInterval(() => this.runScan(), this.config.scanIntervalMs); + // Run immediately + setTimeout(() => this.runScan(), 2000); + } + + stopScanning(): void { + if (this.scanTimer) { clearInterval(this.scanTimer); this.scanTimer = null; } + } + + async runScan(): Promise { + for (const rule of this.config.rules) { + if (!rule.enabled) continue; + + // Check schedule + if ((rule as any).schedule && (rule as any).schedule.mode !== 'interval') { + if (!this.shouldRunBySchedule(rule)) continue; + } + + // Check cooldown + if (rule.lastTriggered) { + const elapsed = Date.now() - new Date(rule.lastTriggered).getTime(); + if (elapsed < rule.cooldownMs) continue; + } + + try { + const matches = this.scanRule(rule); + if (matches.length > 0) { + const result: MonitorResult = { + ruleId: rule.id, + ruleName: rule.name, + matchedLines: matches, + totalMatches: matches.length, + sources: rule.sources, + timestamp: new Date().toISOString(), + }; + + rule.lastTriggered = result.timestamp; + rule.matchCount += matches.length; + this.results.push(result); + + // Send notifications + await this.notify(rule, result); + this.saveState(); + } + } catch (err) { + console.error(` Monitor rule "${rule.name}" error:`, (err as Error).message); + } + } + } + + private shouldRunBySchedule(rule: MonitorRule): boolean { + const schedule = (rule as any).schedule; + if (!schedule) return true; + + const now = new Date(); + const lastRun = rule.lastTriggered ? new Date(rule.lastTriggered) : null; + + switch (schedule.mode) { + case 'daily': { + // Run once per day at specified time (HH:MM) + const [h, m] = (schedule.value || '07:00').split(':').map(Number); + const targetToday = new Date(now); targetToday.setHours(h, m, 0, 0); + // Already ran today? + if (lastRun && lastRun.toDateString() === now.toDateString()) return false; + // Is it past the target time? + return now >= targetToday; + } + case 'hourly': { + // Run every N hours + const hours = parseInt(schedule.value || '2', 10); + if (!lastRun) return true; + return (now.getTime() - lastRun.getTime()) >= hours * 3600000; + } + case 'cron': { + // Simple cron: just check if enough time passed based on scan interval + // Full cron parsing would need a library, so we approximate + return true; + } + default: return true; + } + } + + private scanRule(rule: MonitorRule): string[] { + const allMatches: string[] = []; + const regexes = rule.patterns.map(p => new RegExp(p, 'i')); + + for (const source of rule.sources) { + const files = this.resolveFiles(source); + for (const file of files) { + if (!this.isInTimeRange(file, rule.timeRange)) continue; + + try { + const content = this.readFile(file); + const lines = content.split('\n'); + + for (const line of lines) { + if (line.trim() === '') continue; + const matched = rule.patternLogic === 'all' + ? regexes.every(r => r.test(line)) + : regexes.some(r => r.test(line)); + if (matched) allMatches.push(line); + } + } catch { } + } + } + + return allMatches; + } + + private resolveFiles(pattern: string): string[] { + if (pattern.includes('*')) { + const dir = path.dirname(pattern); + const filePattern = path.basename(pattern); + try { + const files = fs.readdirSync(dir); + const regex = new RegExp('^' + filePattern.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$'); + return files.filter(f => regex.test(f)).map(f => path.join(dir, f)); + } catch { return []; } + } + return fs.existsSync(pattern) ? [pattern] : []; + } + + private isInTimeRange(file: string, range: TimeRange): boolean { + if (range.type === 'all') return true; + + try { + const stat = fs.statSync(file); + const fileTime = stat.mtime.getTime(); + const now = Date.now(); + + switch (range.type) { + case 'today': { + const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0); + return fileTime >= startOfDay.getTime(); + } + case 'week': return fileTime >= now - 7 * 24 * 60 * 60 * 1000; + case 'month': return fileTime >= now - 30 * 24 * 60 * 60 * 1000; + case 'hours': return fileTime >= now - (range.hours || 24) * 60 * 60 * 1000; + default: return true; + } + } catch { return false; } + } + + private readFile(filePath: string): string { + const ext = path.extname(filePath).toLowerCase(); + if (ext === '.gz' || ext === '.gzip') { + return zlib.gunzipSync(fs.readFileSync(filePath)).toString('utf-8'); + } + return fs.readFileSync(filePath, 'utf-8'); + } + + // ---- Notifications ---- + + private async notify(rule: MonitorRule, result: MonitorResult): Promise { + const summary = this.buildSummary(rule, result); + const attachment = result.matchedLines.length > rule.maxLinesInMessage + ? this.buildAttachment(result) + : null; + + for (const channel of rule.notifications) { + try { + switch (channel.type) { + case 'webhook': await this.sendWebhook(channel, summary, attachment); break; + case 'telegram': await this.sendTelegram(channel, summary, attachment); break; + case 'teams': await this.sendTeams(channel, summary); break; + case 'email': console.log(` [Monitor] Email notification not yet implemented`); break; + } + } catch (err) { + console.error(` [Monitor] Notification error (${channel.type}):`, (err as Error).message); + } + } + } + + private buildSummary(rule: MonitorRule, result: MonitorResult): string { + const lines = result.matchedLines.slice(0, rule.maxLinesInMessage); + let msg = `🚨 **Log Monitor Alert: ${rule.name}**\n\n`; + msg += `**Matches:** ${result.totalMatches} lines\n`; + msg += `**Sources:** ${result.sources.join(', ')}\n`; + msg += `**Time:** ${result.timestamp}\n`; + msg += `**Patterns:** ${rule.patterns.join(' | ')}\n\n`; + msg += `**Sample (first ${lines.length} of ${result.totalMatches}):**\n`; + msg += '```\n' + lines.join('\n') + '\n```'; + + if (result.totalMatches > rule.maxLinesInMessage) { + msg += `\n\n📎 Full results (${result.totalMatches} lines) attached as .gz file.`; + } + return msg; + } + + private buildAttachment(result: MonitorResult): Buffer { + const content = result.matchedLines.join('\n'); + return zlib.gzipSync(Buffer.from(content, 'utf-8')); + } + + private async sendWebhook(channel: NotificationChannel, message: string, attachment: Buffer | null): Promise { + if (!channel.url) return; + const payload = JSON.stringify({ + text: message, + matches: attachment ? `(${attachment.length} bytes gzipped attachment)` : undefined, + }); + await this.httpPost(channel.url, payload, 'application/json'); + console.log(` [Monitor] Webhook sent to ${channel.url}`); + } + + private async sendTelegram(channel: NotificationChannel, message: string, attachment: Buffer | null): Promise { + if (!channel.botToken || !channel.chatId) return; + + // Send text message - use plain text to avoid Markdown parsing issues + const payload = JSON.stringify({ + chat_id: channel.chatId, + text: message.replace(/[*_`\[]/g, '').substring(0, 4000), // Strip markdown, respect limit + }); + + await this.httpPost( + `https://api.telegram.org/bot${channel.botToken}/sendMessage`, + payload, + 'application/json' + ); + + console.log(` [Monitor] Telegram sent to chat ${channel.chatId}`); + } + + private async sendTeams(channel: NotificationChannel, message: string): Promise { + if (!channel.url) return; + const payload = JSON.stringify({ + '@type': 'MessageCard', + summary: 'Log Monitor Alert', + text: message.replace(/\n/g, '
').replace(/```/g, ''), + }); + await this.httpPost(channel.url, payload, 'application/json'); + console.log(` [Monitor] Teams webhook sent`); + } + + private httpPost(targetUrl: string, body: string, contentType: string): Promise { + return new Promise((resolve, reject) => { + const parsed = new URL(targetUrl); + const isHttps = parsed.protocol === 'https:'; + const transport = isHttps ? https : http; + const req = transport.request({ + hostname: parsed.hostname, + port: parsed.port || (isHttps ? 443 : 80), + path: parsed.pathname + parsed.search, + method: 'POST', + headers: { 'Content-Type': contentType, 'Content-Length': Buffer.byteLength(body) }, + }, (res) => { + let responseBody = ''; + res.on('data', (c) => { responseBody += c; }); + res.on('end', () => { + if (res.statusCode && res.statusCode >= 400) { + reject(new Error(`HTTP ${res.statusCode}: ${responseBody.substring(0, 200)}`)); + } else { + resolve(); + } + }); + }); + req.on('error', reject); + req.setTimeout(10000, () => { req.destroy(); reject(new Error('Timeout')); }); + req.write(body); + req.end(); + }); + } + + // ---- Public API ---- + + getRules(): MonitorRule[] { return this.config.rules; } + getResults(): MonitorResult[] { return this.results; } + getConfig(): MonitorConfig { return { ...this.config }; } + + addRule(rule: MonitorRule): void { + this.config.rules.push(rule); + this.saveState(); + if (this.config.rules.length === 1 && !this.scanTimer) this.startScanning(); + } + + updateRule(id: string, updates: Partial): boolean { + const rule = this.config.rules.find(r => r.id === id); + if (!rule) return false; + Object.assign(rule, updates); + this.saveState(); + return true; + } + + deleteRule(id: string): boolean { + const idx = this.config.rules.findIndex(r => r.id === id); + if (idx === -1) return false; + this.config.rules.splice(idx, 1); + this.saveState(); + return true; + } + + triggerRule(id: string): MonitorResult | null { + const rule = this.config.rules.find(r => r.id === id); + if (!rule) return null; + const matches = this.scanRule(rule); + if (matches.length === 0) return null; + const result: MonitorResult = { + ruleId: rule.id, ruleName: rule.name, matchedLines: matches, + totalMatches: matches.length, sources: rule.sources, timestamp: new Date().toISOString(), + }; + this.results.push(result); + rule.matchCount += matches.length; + this.saveState(); + return result; + } + + getAttachment(ruleId: string): Buffer | null { + const result = [...this.results].reverse().find(r => r.ruleId === ruleId); + if (!result) return null; + return zlib.gzipSync(Buffer.from(result.matchedLines.join('\n'), 'utf-8')); + } + + handleRequest(req: http.IncomingMessage, res: http.ServerResponse, pathname: string): boolean { + const method = req.method || 'GET'; + + if (pathname === '/api/monitor/rules' && method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(this.getRules())); + return true; + } + + if (pathname === '/api/monitor/rules' && method === 'POST') { + let body = ''; + req.on('data', c => body += c); + req.on('end', () => { + try { + const rule = JSON.parse(body); + if (!rule.id) rule.id = 'rule-' + Date.now(); + if (!rule.maxLinesInMessage) rule.maxLinesInMessage = 20; + if (!rule.cooldownMs) rule.cooldownMs = 300000; + if (!rule.matchCount) rule.matchCount = 0; + if (!rule.lastTriggered) rule.lastTriggered = null; + this.addRule(rule); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, rule })); + } catch (e) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (e as Error).message })); + } + }); + return true; + } + + // Test notification endpoint + if (pathname === '/api/monitor/test-notification' && method === 'POST') { + let body = ''; + req.on('data', c => body += c); + req.on('end', async () => { + try { + const channel = JSON.parse(body); + const testMsg = `🧪 **Test Notification**\n\nThis is a test from LogViewer Monitor.\nTimestamp: ${new Date().toISOString()}\n\nIf you see this, notifications are working! ✅`; + await this.sendTestNotification(channel, testMsg); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + } catch (e) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: (e as Error).message })); + } + }); + return true; + } + + const ruleMatch = pathname.match(/^\/api\/monitor\/rule\/([a-zA-Z0-9_\-]+)$/); + if (ruleMatch && method === 'DELETE') { + const ok = this.deleteRule(ruleMatch[1]); + res.writeHead(ok ? 200 : 404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok })); + return true; + } + + if (ruleMatch && method === 'PUT') { + let body = ''; + req.on('data', c => body += c); + req.on('end', () => { + const updates = JSON.parse(body); + const ok = this.updateRule(ruleMatch[1], updates); + res.writeHead(ok ? 200 : 404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok })); + }); + return true; + } + + const triggerMatch = pathname.match(/^\/api\/monitor\/trigger\/([a-zA-Z0-9_\-]+)$/); + if (triggerMatch && method === 'POST') { + const result = this.triggerRule(triggerMatch[1]); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result || { matches: 0 })); + return true; + } + + if (pathname === '/api/monitor/results' && method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(this.results.slice(-50))); + return true; + } + + if (pathname === '/api/monitor/config' && method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(this.getConfig())); + return true; + } + + return false; + } + + private async sendTestNotification(channel: NotificationChannel, message: string): Promise { + switch (channel.type) { + case 'webhook': + if (!channel.url) throw new Error('No webhook URL provided'); + await this.sendWebhook(channel, message, null); + break; + case 'telegram': + if (!channel.botToken || !channel.chatId) throw new Error('Bot token and chat ID required'); + await this.sendTelegram(channel, message, null); + break; + case 'teams': + if (!channel.url) throw new Error('No Teams webhook URL provided'); + await this.sendTeams(channel, message); + break; + case 'email': + throw new Error('Email test not yet implemented'); + default: + throw new Error(`Unknown channel type: ${channel.type}`); + } + } +} diff --git a/src/server/s3Source.ts b/src/server/s3Source.ts new file mode 100644 index 0000000..021bc9c --- /dev/null +++ b/src/server/s3Source.ts @@ -0,0 +1,191 @@ +import * as https from 'https'; +import * as http from 'http'; +import * as crypto from 'crypto'; +import * as zlib from 'zlib'; + +export interface S3Config { + accessKeyId: string; + secretAccessKey: string; + region: string; + bucket: string; + prefix?: string; + endpoint?: string; // For S3-compatible (MinIO, etc.) +} + +export interface S3Object { + key: string; + size: number; + lastModified: string; +} + +/** + * Minimal S3 client without external dependencies. + * Supports AWS S3 and S3-compatible services (MinIO, DigitalOcean Spaces, etc.) + */ +export class S3Source { + private config: S3Config; + private host: string; + private useHttps: boolean; + + constructor(config: S3Config) { + this.config = config; + if (config.endpoint) { + const url = new URL(config.endpoint); + this.host = url.host; + this.useHttps = url.protocol === 'https:'; + } else { + this.host = `${config.bucket}.s3.${config.region}.amazonaws.com`; + this.useHttps = true; + } + } + + /** + * List objects in the bucket with optional prefix + */ + async listObjects(prefix?: string): Promise { + const usedPrefix = prefix || this.config.prefix || ''; + const queryParams = usedPrefix ? `list-type=2&prefix=${encodeURIComponent(usedPrefix)}` : 'list-type=2'; + const path = this.config.endpoint ? `/${this.config.bucket}/?${queryParams}` : `/?${queryParams}`; + + const response = await this.request('GET', path, ''); + const objects: S3Object[] = []; + + // Simple XML parsing for S3 ListObjectsV2 response + const contentRegex = /([\s\S]*?)<\/Contents>/g; + let match; + while ((match = contentRegex.exec(response)) !== null) { + const content = match[1]; + const key = this.extractXmlValue(content, 'Key'); + const size = parseInt(this.extractXmlValue(content, 'Size') || '0', 10); + const lastModified = this.extractXmlValue(content, 'LastModified') || ''; + + if (key && !key.endsWith('/')) { + objects.push({ key, size, lastModified }); + } + } + + return objects; + } + + /** + * Download an object's content as string + */ + async getObject(key: string): Promise { + const path = this.config.endpoint + ? `/${this.config.bucket}/${encodeURIComponent(key)}` + : `/${encodeURIComponent(key)}`; + + const response = await this.requestRaw('GET', path, ''); + + // Auto-decompress if gzipped + if (key.endsWith('.gz') || key.endsWith('.gzip')) { + try { + const decompressed = zlib.gunzipSync(response); + return decompressed.toString('utf-8'); + } catch { + return response.toString('utf-8'); + } + } + + return response.toString('utf-8'); + } + + private extractXmlValue(xml: string, tag: string): string | null { + const regex = new RegExp(`<${tag}>(.*?)`); + const match = xml.match(regex); + return match ? match[1] : null; + } + + private async request(method: string, path: string, body: string): Promise { + const buf = await this.requestRaw(method, path, body); + return buf.toString('utf-8'); + } + + private async requestRaw(method: string, path: string, body: string): Promise { + const now = new Date(); + const dateStamp = now.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; + const shortDate = dateStamp.substring(0, 8); + + const headers: Record = { + 'host': this.host, + 'x-amz-date': dateStamp, + 'x-amz-content-sha256': this.sha256(body), + }; + + // Create canonical request + const [pathPart, queryPart] = path.split('?'); + const canonicalQueryString = queryPart || ''; + const signedHeaderKeys = Object.keys(headers).sort(); + const signedHeaders = signedHeaderKeys.join(';'); + const canonicalHeaders = signedHeaderKeys.map(k => `${k}:${headers[k]}\n`).join(''); + + const canonicalRequest = [ + method, + pathPart, + canonicalQueryString, + canonicalHeaders, + signedHeaders, + headers['x-amz-content-sha256'], + ].join('\n'); + + // Create string to sign + const credentialScope = `${shortDate}/${this.config.region}/s3/aws4_request`; + const stringToSign = [ + 'AWS4-HMAC-SHA256', + dateStamp, + credentialScope, + this.sha256(canonicalRequest), + ].join('\n'); + + // Calculate signature + const signingKey = this.getSignatureKey(shortDate); + const signature = this.hmac(signingKey, stringToSign).toString('hex'); + + // Authorization header + headers['authorization'] = `AWS4-HMAC-SHA256 Credential=${this.config.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; + + return new Promise((resolve, reject) => { + const options = { + hostname: this.host.split(':')[0], + port: this.host.includes(':') ? parseInt(this.host.split(':')[1]) : (this.useHttps ? 443 : 80), + path: path, + method: method, + headers: headers, + }; + + const transport = this.useHttps ? https : http; + const req = transport.request(options, (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => { + const data = Buffer.concat(chunks); + if (res.statusCode && res.statusCode >= 400) { + reject(new Error(`S3 error ${res.statusCode}: ${data.toString('utf-8').substring(0, 200)}`)); + } else { + resolve(data); + } + }); + }); + + req.on('error', reject); + req.setTimeout(30000, () => { req.destroy(); reject(new Error('S3 request timeout')); }); + if (body) req.write(body); + req.end(); + }); + } + + private sha256(data: string): string { + return crypto.createHash('sha256').update(data, 'utf8').digest('hex'); + } + + private hmac(key: Buffer | string, data: string): Buffer { + return crypto.createHmac('sha256', key).update(data, 'utf8').digest(); + } + + private getSignatureKey(dateStamp: string): Buffer { + const kDate = this.hmac(`AWS4${this.config.secretAccessKey}`, dateStamp); + const kRegion = this.hmac(kDate, this.config.region); + const kService = this.hmac(kRegion, 's3'); + return this.hmac(kService, 'aws4_request'); + } +} diff --git a/src/server/server.ts b/src/server/server.ts new file mode 100644 index 0000000..5ac8e56 --- /dev/null +++ b/src/server/server.ts @@ -0,0 +1,418 @@ +import * as http from 'http'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as url from 'url'; +import { LogSourceManager } from './logSourceManager'; +import { ConfigManager } from './configManager'; +import { LogCollector } from './collector'; +import { S3Source, S3Config } from './s3Source'; +import { saveConfigFile } from './configFile'; +import { LogMonitor, MonitorConfig } from './monitor'; + +const configManager = new ConfigManager(); +const logSourceManager = new LogSourceManager(configManager); + +// Mode: "viewer" | "collector" | "monitor" | "both" | "all" +const MODE = (process.env.LOG_MODE || 'both').toLowerCase(); +const PORT = parseInt(process.env.PORT || '8080', 10); +const HOST = process.env.HOST || '0.0.0.0'; + +// Collector setup +let collector: LogCollector | null = null; +if (MODE === 'collector' || MODE === 'both') { + let forwardTargets: any[] = []; + try { forwardTargets = JSON.parse(process.env.COLLECTOR_FORWARD_TARGETS || '[]'); } catch { } + + collector = new LogCollector({ + storageDir: process.env.COLLECTOR_STORAGE_DIR || path.join(process.cwd(), 'collector-data'), + maxLinesPerStream: parseInt(process.env.COLLECTOR_MAX_LINES || '100000', 10), + maxStreams: parseInt(process.env.COLLECTOR_MAX_STREAMS || '50', 10), + udpEnabled: process.env.COLLECTOR_UDP_ENABLED !== 'false', + udpPort: parseInt(process.env.COLLECTOR_UDP_PORT || '5140', 10), + persistToDisk: process.env.COLLECTOR_PERSIST !== 'false', + rotation: { + maxSizeBytes: parseInt(process.env.COLLECTOR_ROTATION_MAX_SIZE || '5242880', 10), + maxAgeMs: parseInt(process.env.COLLECTOR_ROTATION_MAX_AGE || '600000', 10), + compression: (process.env.COLLECTOR_ROTATION_COMPRESSION as any) || 'gzip', + fileExtension: process.env.COLLECTOR_ROTATION_EXTENSION || '.log', + }, + forwardTargets, + }); +} + +// S3 sources from env +const s3Sources: Map = new Map(); +function loadS3SourcesFromEnv(): void { + // Format: S3_SOURCE_=bucket:prefix + // S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_REGION (global) + // Or per-source: S3_SOURCE__ACCESS_KEY, S3_SOURCE__SECRET_KEY, etc. + const globalAccessKey = process.env.S3_ACCESS_KEY_ID || process.env.AWS_ACCESS_KEY_ID || ''; + const globalSecretKey = process.env.S3_SECRET_ACCESS_KEY || process.env.AWS_SECRET_ACCESS_KEY || ''; + const globalRegion = process.env.S3_REGION || process.env.AWS_REGION || 'us-east-1'; + const globalEndpoint = process.env.S3_ENDPOINT || ''; + + for (const [key, value] of Object.entries(process.env)) { + if (key.startsWith('S3_SOURCE_') && value && !key.includes('_ACCESS_KEY') && !key.includes('_SECRET_KEY') && !key.includes('_REGION') && !key.includes('_ENDPOINT')) { + const name = key.replace('S3_SOURCE_', '').toLowerCase(); + const parts = value.split(':'); + const bucket = parts[0]; + const prefix = parts.slice(1).join(':') || ''; + + const accessKey = process.env[`S3_SOURCE_${name.toUpperCase()}_ACCESS_KEY`] || globalAccessKey; + const secretKey = process.env[`S3_SOURCE_${name.toUpperCase()}_SECRET_KEY`] || globalSecretKey; + const region = process.env[`S3_SOURCE_${name.toUpperCase()}_REGION`] || globalRegion; + const endpoint = process.env[`S3_SOURCE_${name.toUpperCase()}_ENDPOINT`] || globalEndpoint; + + if (accessKey && secretKey && bucket) { + const config: S3Config = { accessKeyId: accessKey, secretAccessKey: secretKey, region, bucket, prefix: prefix || undefined, endpoint: endpoint || undefined }; + s3Sources.set(name, new S3Source(config)); + } + } + } +} +loadS3SourcesFromEnv(); + +// Monitor setup +let monitor: LogMonitor | null = null; +if (MODE === 'monitor' || MODE === 'both' || MODE === 'all') { + let monitorRules: any[] = []; + try { monitorRules = JSON.parse(process.env.MONITOR_RULES || '[]'); } catch { } + + monitor = new LogMonitor({ + enabled: process.env.MONITOR_ENABLED !== 'false', + scanIntervalMs: parseInt(process.env.MONITOR_SCAN_INTERVAL || '60000', 10), + rules: monitorRules, + storageDir: process.env.MONITOR_STORAGE_DIR || path.join(process.cwd(), 'monitor-data'), + }); +} + +// Remote collector URL (for viewer mode connecting to a remote collector) +const REMOTE_COLLECTOR_URL = process.env.REMOTE_COLLECTOR_URL || ''; + +const MIME_TYPES: Record = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', +}; + +function sendJson(res: http.ServerResponse, data: unknown, status = 200): void { + res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify(data)); +} + +function sendError(res: http.ServerResponse, message: string, status = 500): void { + sendJson(res, { error: message }, status); +} + +async function readBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); + req.on('error', reject); + }); +} + +async function fetchRemote(remotePath: string): Promise { + if (!REMOTE_COLLECTOR_URL) throw new Error('No remote collector configured'); + const fullUrl = REMOTE_COLLECTOR_URL.replace(/\/$/, '') + remotePath; + + return new Promise((resolve, reject) => { + const transport = fullUrl.startsWith('https') ? require('https') : require('http'); + transport.get(fullUrl, (res: http.IncomingMessage) => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8'))); + } catch { resolve(Buffer.concat(chunks).toString('utf-8')); } + }); + }).on('error', reject); + }); +} + +const server = http.createServer(async (req, res) => { + const parsedUrl = url.parse(req.url || '/', true); + const pathname = parsedUrl.pathname || '/'; + const method = req.method || 'GET'; + + // CORS + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Stream-Name'); + + if (method === 'OPTIONS') { res.writeHead(204); res.end(); return; } + + // ---- Collector endpoints ---- + if (collector && (pathname.startsWith('/collect/') || pathname.startsWith('/collector/'))) { + const handled = collector.handleRequest(req, res, pathname); + if (handled) return; + } + + // ---- Monitor endpoints ---- + if (monitor && pathname.startsWith('/api/monitor/')) { + const handled = monitor.handleRequest(req, res, pathname); + if (handled) return; + } + + // ---- API Routes ---- + if (pathname.startsWith('/api/')) { + try { + // GET /api/mode + if (pathname === '/api/mode' && method === 'GET') { + return sendJson(res, { mode: MODE, hasCollector: !!collector, hasRemoteCollector: !!REMOTE_COLLECTOR_URL, s3Sources: Array.from(s3Sources.keys()) }); + } + + // GET /api/config + if (pathname === '/api/config' && method === 'GET') { + return sendJson(res, { ...configManager.getConfig(), mode: MODE }); + } + + // GET /api/sources + if (pathname === '/api/sources' && method === 'GET') { + return sendJson(res, logSourceManager.getSources()); + } + + // POST /api/sources { path: "..." } + if (pathname === '/api/sources' && method === 'POST') { + const body = JSON.parse(await readBody(req)); + logSourceManager.addSource(body.path); + return sendJson(res, logSourceManager.getSources()); + } + + // DELETE /api/sources?path=... + if (pathname === '/api/sources' && method === 'DELETE') { + const sourcePath = parsedUrl.query.path as string; + if (!sourcePath) return sendError(res, 'Missing path parameter', 400); + logSourceManager.removeSource(sourcePath); + return sendJson(res, logSourceManager.getSources()); + } + + // GET /api/logs?source=... + if (pathname === '/api/logs' && method === 'GET') { + const source = parsedUrl.query.source as string | undefined; + if (source && source !== 'ALL') { + const entries = await logSourceManager.readLog(source); + return sendJson(res, entries); + } else { + const entries = await logSourceManager.readAllLogs(); + return sendJson(res, entries); + } + } + + // ---- S3 endpoints ---- + + // POST /api/s3/add { name, bucket, prefix, accessKeyId, secretAccessKey, region, endpoint? } + if (pathname === '/api/s3/add' && method === 'POST') { + const body = JSON.parse(await readBody(req)); + const { name, bucket, prefix, accessKeyId, secretAccessKey, region, endpoint } = body; + if (!name || !bucket || !accessKeyId || !secretAccessKey || !region) { + return sendError(res, 'Missing required fields: name, bucket, accessKeyId, secretAccessKey, region', 400); + } + const config: S3Config = { accessKeyId, secretAccessKey, region, bucket, prefix, endpoint }; + s3Sources.set(name, new S3Source(config)); + return sendJson(res, { ok: true, name }); + } + + // GET /api/s3/sources + if (pathname === '/api/s3/sources' && method === 'GET') { + return sendJson(res, Array.from(s3Sources.keys())); + } + + // GET /api/s3/list?name=...&prefix=... + if (pathname === '/api/s3/list' && method === 'GET') { + const name = parsedUrl.query.name as string; + const prefix = parsedUrl.query.prefix as string | undefined; + const s3 = s3Sources.get(name); + if (!s3) return sendError(res, `S3 source not found: ${name}`, 404); + const objects = await s3.listObjects(prefix); + return sendJson(res, objects); + } + + // GET /api/s3/read?name=...&key=... + if (pathname === '/api/s3/read' && method === 'GET') { + const name = parsedUrl.query.name as string; + const key = parsedUrl.query.key as string; + const s3 = s3Sources.get(name); + if (!s3) return sendError(res, `S3 source not found: ${name}`, 404); + if (!key) return sendError(res, 'Missing key parameter', 400); + const content = await s3.getObject(key); + const lines = content.split('\n').filter(l => l.trim() !== ''); + const entries = lines.map((line, i) => ({ + line, + lineNumber: i + 1, + source: `s3://${name}/${key}`, + timestamp: extractTimestamp(line), + level: extractLevel(line), + })); + return sendJson(res, entries); + } + + // DELETE /api/s3/remove?name=... + if (pathname === '/api/s3/remove' && method === 'DELETE') { + const name = parsedUrl.query.name as string; + if (!name) return sendError(res, 'Missing name parameter', 400); + s3Sources.delete(name); + return sendJson(res, { ok: true }); + } + + // ---- Remote Collector endpoints (viewer reads from remote collector) ---- + + // GET /api/remote/streams + if (pathname === '/api/remote/streams' && method === 'GET') { + if (!REMOTE_COLLECTOR_URL) return sendJson(res, []); + try { + const streams = await fetchRemote('/collector/streams'); + return sendJson(res, streams); + } catch (err) { + return sendError(res, `Cannot reach remote collector: ${(err as Error).message}`); + } + } + + // GET /api/remote/stream/:id?tail=N + const remoteStreamMatch = pathname.match(/^\/api\/remote\/stream\/([a-zA-Z0-9_\-\.]+)$/); + if (remoteStreamMatch && method === 'GET') { + const streamId = remoteStreamMatch[1]; + const tail = parsedUrl.query.tail as string | undefined; + const tailParam = tail ? `?tail=${tail}` : ''; + try { + const data = await fetchRemote(`/collector/stream/${streamId}${tailParam}`); + // Convert to LogEntry format + const lines: string[] = data.lines || []; + const entries = lines.map((line: string, i: number) => ({ + line, + lineNumber: i + 1, + source: `collector://${streamId}`, + timestamp: extractTimestamp(line), + level: extractLevel(line), + })); + return sendJson(res, entries); + } catch (err) { + return sendError(res, (err as Error).message); + } + } + + // ---- Collector streams as log source (local) ---- + + // GET /api/collector/streams + if (pathname === '/api/collector/streams' && method === 'GET') { + if (!collector) return sendJson(res, []); + return sendJson(res, collector.getStreams()); + } + + // GET /api/collector/stream/:id + const localStreamMatch = pathname.match(/^\/api\/collector\/stream\/([a-zA-Z0-9_\-\.]+)$/); + if (localStreamMatch && method === 'GET') { + const streamId = localStreamMatch[1]; + if (!collector) return sendError(res, 'Collector not enabled', 400); + try { + const tail = parsedUrl.query.tail as string | undefined; + const lines = collector.readStream(streamId, tail ? parseInt(tail, 10) : undefined); + const entries = lines.map((line, i) => ({ + line, + lineNumber: i + 1, + source: `stream://${streamId}`, + timestamp: extractTimestamp(line), + level: extractLevel(line), + })); + return sendJson(res, entries); + } catch (err) { + return sendError(res, (err as Error).message, 404); + } + } + + // POST /api/config/save - Save config to file + if (pathname === '/api/config/save' && method === 'POST') { + const body = JSON.parse(await readBody(req)); + saveConfigFile(body); + return sendJson(res, { ok: true }); + } + + return sendError(res, 'Not found', 404); + } catch (err) { + console.error('API error:', err); + return sendError(res, (err as Error).message); + } + } + + // ---- Static Files ---- + const webDir = path.join(__dirname, '..', 'src', 'web'); + let filePath: string; + + // Serve collector UI when in collector mode, monitor UI when in monitor mode + if (pathname === '/' || pathname === '/index.html') { + if (MODE === 'collector') { + filePath = path.join(webDir, 'collector.html'); + } else if (MODE === 'monitor') { + filePath = path.join(webDir, 'monitor.html'); + } else { + filePath = path.join(webDir, 'index.html'); + } + } else { + filePath = path.join(webDir, pathname); + } + + const webRoot = path.resolve(webDir); + const resolvedPath = path.resolve(filePath); + if (!resolvedPath.startsWith(webRoot)) { + res.writeHead(403); res.end('Forbidden'); return; + } + + try { + if (!fs.existsSync(resolvedPath) || fs.statSync(resolvedPath).isDirectory()) { + res.writeHead(404); res.end('Not found'); return; + } + const ext = path.extname(resolvedPath); + const contentType = MIME_TYPES[ext] || 'application/octet-stream'; + res.writeHead(200, { 'Content-Type': contentType }); + res.end(fs.readFileSync(resolvedPath)); + } catch { + res.writeHead(500); res.end('Internal server error'); + } +}); + +// Helper functions +function extractTimestamp(line: string): string | null { + const patterns = [ + /(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)/, + /^([A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})/, + /\[(\d{2}\/[A-Z][a-z]{2}\/\d{4}:\d{2}:\d{2}:\d{2}\s[+-]\d{4})\]/, + ]; + for (const p of patterns) { const m = line.match(p); if (m) return m[1]; } + return null; +} + +function extractLevel(line: string): string | null { + const upper = line.toUpperCase(); + for (const level of ['FATAL', 'ERROR', 'WARN', 'WARNING', 'INFO', 'DEBUG', 'TRACE']) { + if (new RegExp(`\\b${level}\\b`).test(upper)) return level === 'WARNING' ? 'WARN' : level; + } + return null; +} + +server.listen(PORT, HOST, () => { + console.log(''); + console.log(' ╔══════════════════════════════════════════════════╗'); + console.log(` ║ 📋 Log Viewer - Mode: ${MODE.toUpperCase().padEnd(24)}║`); + console.log(' ╠══════════════════════════════════════════════════╣'); + console.log(` ║ URL: http://${HOST}:${PORT} ║`); + console.log(` ║ Sources: ${String(logSourceManager.getSources().length).padEnd(3)} file(s) ║`); + console.log(` ║ S3 Sources: ${String(s3Sources.size).padEnd(3)} configured ║`); + console.log(` ║ Collector: ${collector ? 'ACTIVE' : 'OFF'} ║`); + console.log(` ║ Monitor: ${monitor ? 'ACTIVE' : 'OFF'} ║`); + if (REMOTE_COLLECTOR_URL) { + console.log(` ║ Remote: ${REMOTE_COLLECTOR_URL.substring(0, 30).padEnd(30)} ║`); + } + console.log(' ╚══════════════════════════════════════════════════╝'); + console.log(''); + if (collector) { + console.log(' Collector endpoints:'); + console.log(` POST http://${HOST}:${PORT}/collect/ (plain text, one line per line)`); + console.log(` POST http://${HOST}:${PORT}/collect//json (JSON array of entries)`); + console.log(''); + } +}); diff --git a/src/web/app.js b/src/web/app.js new file mode 100644 index 0000000..824c7e8 --- /dev/null +++ b/src/web/app.js @@ -0,0 +1,521 @@ +// ============================================ +// Log Viewer - Web Frontend (with S3 + Collector) +// ============================================ +(function () { + 'use strict'; + + const API = ''; + + const state = { + allEntries: [], + filteredEntries: [], + sources: [], + activeSource: 'ALL', + activeLevel: 'ALL', + searchQuery: '', + regexMode: false, + caseSensitive: false, + tailMode: true, + wordWrap: false, + showTimestamps: true, + showSourceColumn: true, + bookmarks: new Set(), + zoomLevel: 100, + theme: 'dark', + refreshInterval: 3000, + refreshTimer: null, + isLoading: false, + contextMenu: null, + mode: 'both', + s3Sources: [], + collectorStreams: [], + remoteStreams: [], + }; + + const $ = (s) => document.querySelector(s); + const $$ = (s) => document.querySelectorAll(s); + + const el = { + logOutput: $('#log-output'), + searchInput: $('#search-input'), + searchCount: $('#search-count'), + levelFilter: $('#level-filter'), + sourceFilter: $('#source-filter'), + sourceList: $('#source-list'), + emptySources: $('#empty-sources'), + welcomeMessage: $('#welcome-message'), + statLines: $('#stat-lines'), + statFiltered: $('#stat-filtered'), + statusMessage: $('#status-message'), + statusTail: $('#status-tail'), + statusWrap: $('#status-wrap'), + statusRegex: $('#status-regex'), + statusZoom: $('#status-zoom'), + currentSourceName: $('#current-source-name'), + currentSourceInfo: $('#current-source-info'), + themeIcon: $('#theme-icon'), + sidebar: $('#sidebar'), + exportModal: $('#export-modal'), + gotoModal: $('#goto-modal'), + gotoInput: $('#goto-input'), + addSourceModal: $('#add-source-modal'), + addSourceInput: $('#add-source-input'), + }; + + // ---- API helpers ---- + async function api(path, opts = {}) { + const res = await fetch(API + path, { headers: { 'Content-Type': 'application/json' }, ...opts }); + return res.json(); + } + + // ---- Init ---- + async function init() { + try { + const config = await api('/api/config'); + state.theme = config.theme || 'dark'; + state.tailMode = config.tailMode !== false; + state.refreshInterval = config.refreshInterval || 3000; + state.activeLevel = config.defaultLevel || 'ALL'; + state.mode = config.mode || 'both'; + applyTheme(); + el.levelFilter.value = state.activeLevel; + + // Load mode info + const modeInfo = await api('/api/mode'); + state.s3Sources = modeInfo.s3Sources || []; + + await loadSources(); + await loadCollectorStreams(); + await loadRemoteStreams(); + + if (state.sources.length > 0 || state.collectorStreams.length > 0) await loadAllLogs(); + + setupEvents(); + startAutoRefresh(); + updateStatusBar(); + setStatus(`Ready · Mode: ${state.mode.toUpperCase()}`); + } catch (err) { + setStatus('Error: ' + err.message); + } + } + + // ---- Sources ---- + async function loadSources() { + state.sources = await api('/api/sources'); + renderSourceList(); + updateSourceFilter(); + } + + async function loadCollectorStreams() { + try { state.collectorStreams = await api('/api/collector/streams'); } catch { state.collectorStreams = []; } + } + + async function loadRemoteStreams() { + try { state.remoteStreams = await api('/api/remote/streams'); } catch { state.remoteStreams = []; } + } + + function renderSourceList() { + const list = el.sourceList; + list.querySelectorAll('.source-item').forEach((e) => e.remove()); + list.querySelectorAll('.source-section-header').forEach((e) => e.remove()); + + const allSources = []; + + // File sources + if (state.sources.length > 0) { + addSectionHeader(list, '📁 Files'); + for (const src of state.sources) { + allSources.push({ type: 'file', ...src }); + addSourceItem(list, src.displayName, src.path, src.exists, fmtSize(src.size), src.type === 'text' ? '📄' : '📦', () => selectSource(src.path), async () => { + await api('/api/sources?path=' + encodeURIComponent(src.path), { method: 'DELETE' }); + await loadSources(); + }); + } + } + + // S3 sources + if (state.s3Sources.length > 0) { + addSectionHeader(list, '☁️ S3'); + for (const name of state.s3Sources) { + addSourceItem(list, name, `s3://${name}`, true, 'S3', '☁️', () => loadS3Source(name), async () => { + await api('/api/s3/remove?name=' + encodeURIComponent(name), { method: 'DELETE' }); + state.s3Sources = state.s3Sources.filter(n => n !== name); + renderSourceList(); + }); + } + } + + // Collector streams (local) + if (state.collectorStreams.length > 0) { + addSectionHeader(list, '📡 Collector Streams'); + for (const stream of state.collectorStreams) { + addSourceItem(list, stream.name || stream.id, `stream://${stream.id}`, true, `${stream.lineCount} lines`, '📡', () => loadCollectorStream(stream.id), async () => { + await fetch(`/collector/stream/${stream.id}`, { method: 'DELETE' }); + await loadCollectorStreams(); + renderSourceList(); + }); + } + } + + // Remote collector streams + if (state.remoteStreams.length > 0) { + addSectionHeader(list, '🌐 Remote Streams'); + for (const stream of state.remoteStreams) { + addSourceItem(list, stream.name || stream.id, `remote://${stream.id}`, true, `${stream.lineCount} lines`, '🌐', () => loadRemoteStream(stream.id), null); + } + } + + el.emptySources.style.display = (state.sources.length === 0 && state.collectorStreams.length === 0 && state.remoteStreams.length === 0 && state.s3Sources.length === 0) ? 'block' : 'none'; + } + + function addSectionHeader(list, text) { + const h = document.createElement('div'); + h.className = 'source-section-header'; + h.textContent = text; + h.style.cssText = 'padding:6px 10px;font-size:11px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-top:8px;'; + list.appendChild(h); + } + + function addSourceItem(list, name, id, online, meta, icon, onClick, onRemove) { + const item = document.createElement('div'); + item.className = 'source-item' + (state.activeSource === id ? ' active' : ''); + item.innerHTML = ` + + ${icon} +
+
${name}
+
${meta}
+
+ ${onRemove ? '' : ''}`; + item.addEventListener('click', (e) => { if (!e.target.closest('.source-remove')) onClick(); }); + if (onRemove) item.querySelector('.source-remove').addEventListener('click', (e) => { e.stopPropagation(); onRemove(); }); + list.appendChild(item); + } + + function updateSourceFilter() { + const sel = el.sourceFilter; + const cur = sel.value; + while (sel.options.length > 1) sel.remove(1); + for (const src of state.sources) { const o = document.createElement('option'); o.value = src.path; o.textContent = src.displayName; sel.appendChild(o); } + for (const s of state.collectorStreams) { const o = document.createElement('option'); o.value = `stream://${s.id}`; o.textContent = `📡 ${s.name || s.id}`; sel.appendChild(o); } + for (const s of state.remoteStreams) { const o = document.createElement('option'); o.value = `remote://${s.id}`; o.textContent = `🌐 ${s.name || s.id}`; sel.appendChild(o); } + sel.value = cur || 'ALL'; + } + + async function selectSource(p) { + state.activeSource = p; + el.currentSourceName.textContent = p === 'ALL' ? 'All Sources' : p.split(/[/\\]/).pop(); + el.sourceFilter.value = p; + renderSourceList(); + if (p === 'ALL') await loadAllLogs(); + else await loadLogForSource(p); + } + + // ---- S3 Loading ---- + async function loadS3Source(name) { + state.activeSource = `s3://${name}`; + el.currentSourceName.textContent = `S3: ${name}`; + setStatus('Loading S3 objects...'); + try { + const objects = await api(`/api/s3/list?name=${encodeURIComponent(name)}`); + if (objects.length === 0) { setStatus('No objects found'); state.allEntries = []; applyFilters(); return; } + // Load first object (or all small ones) + let allEntries = []; + for (const obj of objects.slice(0, 10)) { // Limit to 10 files + try { + const entries = await api(`/api/s3/read?name=${encodeURIComponent(name)}&key=${encodeURIComponent(obj.key)}`); + allEntries.push(...entries); + } catch (e) { console.warn('S3 read error:', obj.key, e); } + } + state.allEntries = allEntries; + applyFilters(); + setStatus(`Loaded ${allEntries.length} lines from S3 (${objects.length} objects)`); + } catch (e) { setStatus('S3 Error: ' + e.message); } + renderSourceList(); + } + + // ---- Collector Stream Loading ---- + async function loadCollectorStream(streamId) { + state.activeSource = `stream://${streamId}`; + el.currentSourceName.textContent = `Stream: ${streamId}`; + setStatus('Loading stream...'); + try { + const entries = await api(`/api/collector/stream/${streamId}`); + state.allEntries = entries; + applyFilters(); + setStatus(`${entries.length} lines from stream ${streamId}`); + } catch (e) { setStatus('Error: ' + e.message); } + renderSourceList(); + } + + async function loadRemoteStream(streamId) { + state.activeSource = `remote://${streamId}`; + el.currentSourceName.textContent = `Remote: ${streamId}`; + setStatus('Loading remote stream...'); + try { + const entries = await api(`/api/remote/stream/${streamId}`); + state.allEntries = entries; + applyFilters(); + setStatus(`${entries.length} lines from remote ${streamId}`); + } catch (e) { setStatus('Error: ' + e.message); } + renderSourceList(); + } + + // ---- Log Loading ---- + async function loadAllLogs() { + if (state.isLoading) return; + state.isLoading = true; + setStatus('Loading...'); + try { + state.allEntries = await api('/api/logs'); + applyFilters(); + setStatus(`${state.allEntries.length} lines from ${state.sources.length} source(s)`); + } catch (e) { setStatus('Error: ' + e.message); } + finally { state.isLoading = false; } + } + + async function loadLogForSource(p) { + if (state.isLoading) return; + state.isLoading = true; + setStatus('Loading...'); + try { + state.allEntries = await api('/api/logs?source=' + encodeURIComponent(p)); + applyFilters(); + setStatus(`${state.allEntries.length} lines`); + } catch (e) { setStatus('Error: ' + e.message); } + finally { state.isLoading = false; } + } + + // ---- Filtering ---- + function applyFilters() { + let entries = [...state.allEntries]; + if (state.activeLevel !== 'ALL') entries = entries.filter((e) => e.level === state.activeLevel); + + if (state.searchQuery) { + const q = state.searchQuery; + let matcher; + if (state.regexMode) { + try { matcher = new RegExp(q, state.caseSensitive ? 'g' : 'gi'); } + catch { el.searchInput.style.borderColor = 'var(--error)'; el.searchCount.textContent = 'Invalid regex'; state.filteredEntries = entries; renderLogs(); return; } + } + el.searchInput.style.borderColor = ''; + entries = entries.filter((e) => { + if (state.regexMode) { matcher.lastIndex = 0; return matcher.test(e.line); } + const line = state.caseSensitive ? e.line : e.line.toLowerCase(); + return line.includes(state.caseSensitive ? q : q.toLowerCase()); + }); + el.searchCount.textContent = `${entries.length} match${entries.length !== 1 ? 'es' : ''}`; + } else { el.searchCount.textContent = ''; el.searchInput.style.borderColor = ''; } + + state.filteredEntries = entries; + renderLogs(); + updateStats(); + } + + // ---- Rendering ---- + function renderLogs() { + const out = el.logOutput; + const atBottom = isAtBottom(); + if (el.welcomeMessage) el.welcomeMessage.style.display = state.allEntries.length > 0 ? 'none' : 'flex'; + const frag = document.createDocumentFragment(); + for (const entry of state.filteredEntries) frag.appendChild(mkLine(entry)); + out.querySelectorAll('.log-line').forEach((e) => e.remove()); + out.appendChild(frag); + if (state.tailMode && atBottom) scrollBottom(); + } + + function mkLine(entry) { + const div = document.createElement('div'); + div.className = 'log-line'; + div.dataset.lineNumber = entry.lineNumber; + if (entry.level) { + const ll = entry.level.toLowerCase(); + if (ll === 'error' || ll === 'fatal') div.classList.add('line-error'); + else if (ll === 'warn' || ll === 'warning') div.classList.add('line-warn'); + } + if (state.bookmarks.has(`${entry.source}:${entry.lineNumber}`)) div.classList.add('bookmarked'); + + const ln = document.createElement('span'); ln.className = 'log-line-number'; ln.textContent = entry.lineNumber; ln.addEventListener('click', () => toggleBookmark(entry)); div.appendChild(ln); + + if (state.showSourceColumn && state.activeSource === 'ALL') { + const sc = document.createElement('span'); sc.className = 'log-line-source'; sc.textContent = entry.source.split(/[/\\]/).pop(); sc.title = entry.source; div.appendChild(sc); + } + if (state.showTimestamps && entry.timestamp) { + const ts = document.createElement('span'); ts.className = 'log-line-timestamp'; ts.textContent = entry.timestamp; div.appendChild(ts); + } + if (entry.level) { + const lv = document.createElement('span'); lv.className = `log-line-level level-${entry.level.toLowerCase()}`; lv.textContent = entry.level; div.appendChild(lv); + } + const ct = document.createElement('span'); ct.className = 'log-line-content'; ct.innerHTML = state.searchQuery ? highlight(esc(entry.line)) : esc(entry.line); div.appendChild(ct); + + div.addEventListener('contextmenu', (e) => { + e.preventDefault(); + showCtx(e, [ + { label: '📋 Copy Line', action: () => navigator.clipboard.writeText(entry.line) }, + { label: '🔖 Toggle Bookmark', action: () => toggleBookmark(entry) }, + { sep: true }, + { label: '🔍 Filter this level', action: () => { if (entry.level) { state.activeLevel = entry.level; el.levelFilter.value = entry.level; applyFilters(); } } }, + { sep: true }, + { label: '📋 Copy all visible', action: () => navigator.clipboard.writeText(state.filteredEntries.map((e) => e.line).join('\n')) }, + ]); + }); + return div; + } + + function highlight(html) { + if (!state.searchQuery) return html; + try { + const p = state.regexMode ? state.searchQuery : state.searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return html.replace(new RegExp(`(${p})`, state.caseSensitive ? 'g' : 'gi'), '$1'); + } catch { return html; } + } + + function toggleBookmark(e) { const k = `${e.source}:${e.lineNumber}`; state.bookmarks.has(k) ? state.bookmarks.delete(k) : state.bookmarks.add(k); renderLogs(); } + + // ---- Context Menu ---- + function showCtx(event, items) { + hideCtx(); + const menu = document.createElement('div'); menu.className = 'context-menu'; + for (const it of items) { + if (it.sep) { const s = document.createElement('div'); s.className = 'context-menu-separator'; menu.appendChild(s); } + else { const mi = document.createElement('div'); mi.className = 'context-menu-item'; mi.textContent = it.label; mi.addEventListener('click', () => { it.action(); hideCtx(); }); menu.appendChild(mi); } + } + document.body.appendChild(menu); + menu.style.left = Math.min(event.clientX, innerWidth - menu.offsetWidth - 10) + 'px'; + menu.style.top = Math.min(event.clientY, innerHeight - menu.offsetHeight - 10) + 'px'; + state.contextMenu = menu; + setTimeout(() => document.addEventListener('click', hideCtx, { once: true }), 0); + } + function hideCtx() { if (state.contextMenu) { state.contextMenu.remove(); state.contextMenu = null; } } + + // ---- Auto Refresh ---- + function startAutoRefresh() { + if (state.refreshTimer) clearInterval(state.refreshTimer); + state.refreshTimer = setInterval(async () => { + if (!state.tailMode || state.isLoading) return; + const prevCount = state.allEntries.length; + const src = state.activeSource; + if (src === 'ALL') await loadAllLogs(); + else if (src.startsWith('stream://')) await loadCollectorStream(src.replace('stream://', '')); + else if (src.startsWith('remote://')) await loadRemoteStream(src.replace('remote://', '')); + else if (!src.startsWith('s3://')) await loadLogForSource(src); + + // Highlight new lines + if (state.allEntries.length > prevCount) { + const newCount = state.allEntries.length - prevCount; + const logLines = el.logOutput.querySelectorAll('.log-line'); + const startIdx = Math.max(0, logLines.length - newCount); + for (let i = startIdx; i < logLines.length; i++) { + logLines[i].classList.add('new-line'); + } + } + + // Also refresh stream list + await loadCollectorStreams(); + await loadRemoteStreams(); + }, state.refreshInterval); + } + + // ---- Theme / Zoom ---- + function toggleTheme() { state.theme = state.theme === 'dark' ? 'light' : 'dark'; applyTheme(); } + function applyTheme() { document.body.className = `theme-${state.theme}`; el.themeIcon.textContent = state.theme === 'dark' ? '🌙' : '☀️'; } + function zoomIn() { state.zoomLevel = Math.min(state.zoomLevel + 10, 200); applyZoom(); } + function zoomOut() { state.zoomLevel = Math.max(state.zoomLevel - 10, 50); applyZoom(); } + function zoomReset() { state.zoomLevel = 100; applyZoom(); } + function applyZoom() { el.logOutput.style.fontSize = (12 * state.zoomLevel / 100) + 'px'; el.statusZoom.textContent = state.zoomLevel + '%'; } + + // ---- Helpers ---- + function esc(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; } + function isAtBottom() { const e = el.logOutput; return e.scrollHeight - e.scrollTop - e.clientHeight < 50; } + function scrollBottom() { el.logOutput.scrollTop = el.logOutput.scrollHeight; } + function scrollTop() { el.logOutput.scrollTop = 0; } + function setStatus(m) { el.statusMessage.textContent = m; } + function updateStats() { el.statLines.textContent = `${state.allEntries.length} lines`; el.statFiltered.textContent = `${state.filteredEntries.length} shown`; } + function updateStatusBar() { el.statusTail.classList.toggle('active', state.tailMode); el.statusWrap.classList.toggle('active', state.wordWrap); el.statusRegex.classList.toggle('active', state.regexMode); } + function fmtSize(b) { if (!b) return '0 B'; const u = ['B','KB','MB','GB']; const i = Math.floor(Math.log(b)/Math.log(1024)); return (b/Math.pow(1024,i)).toFixed(i>0?1:0)+' '+u[i]; } + function showModal(m) { m.hidden = false; const inp = m.querySelector('input'); if (inp) inp.focus(); } + function hideModal(m) { m.hidden = true; } + function downloadFile(content, filename) { const blob = new Blob([content], { type: 'text/plain' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename; a.click(); URL.revokeObjectURL(a.href); } + + // ---- Events ---- + function setupEvents() { + // Add source (file path) + $('#btn-add-source').addEventListener('click', () => { el.addSourceInput.value = ''; showModal(el.addSourceModal); }); + $('#close-add-source').addEventListener('click', () => hideModal(el.addSourceModal)); + $('#add-source-btn').addEventListener('click', async () => { + const p = el.addSourceInput.value.trim(); + if (!p) return; + await api('/api/sources', { method: 'POST', body: JSON.stringify({ path: p }) }); + hideModal(el.addSourceModal); + await loadSources(); await loadAllLogs(); + }); + if (el.addSourceInput) el.addSourceInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') $('#add-source-btn').click(); }); + + // Add S3 source + const btnS3 = $('#btn-add-s3'); + if (btnS3) { + btnS3.addEventListener('click', () => showModal($('#s3-modal'))); + $('#close-s3').addEventListener('click', () => hideModal($('#s3-modal'))); + $('#add-s3-btn').addEventListener('click', async () => { + const name = $('#s3-name').value.trim(); + const bucket = $('#s3-bucket').value.trim(); + const prefix = $('#s3-prefix').value.trim(); + const accessKeyId = $('#s3-access-key').value.trim(); + const secretAccessKey = $('#s3-secret-key').value.trim(); + const region = $('#s3-region').value.trim(); + const endpoint = $('#s3-endpoint').value.trim(); + if (!name || !bucket || !accessKeyId || !secretAccessKey || !region) { alert('Fill all required fields'); return; } + await api('/api/s3/add', { method: 'POST', body: JSON.stringify({ name, bucket, prefix, accessKeyId, secretAccessKey, region, endpoint: endpoint || undefined }) }); + state.s3Sources.push(name); + hideModal($('#s3-modal')); + renderSourceList(); + }); + } + + $('#btn-reload').addEventListener('click', async () => { await loadSources(); await loadCollectorStreams(); await loadRemoteStreams(); renderSourceList(); if (state.activeSource === 'ALL') await loadAllLogs(); else await selectSource(state.activeSource); }); + + $('#btn-tail').addEventListener('click', () => { state.tailMode = !state.tailMode; $('#btn-tail').classList.toggle('active', state.tailMode); updateStatusBar(); if (state.tailMode) scrollBottom(); }); + $('#btn-wrap').addEventListener('click', () => { state.wordWrap = !state.wordWrap; $('#btn-wrap').classList.toggle('active', state.wordWrap); el.logOutput.classList.toggle('word-wrap', state.wordWrap); updateStatusBar(); }); + $('#btn-timestamps').addEventListener('click', () => { state.showTimestamps = !state.showTimestamps; $('#btn-timestamps').classList.toggle('active', state.showTimestamps); renderLogs(); }); + + el.searchInput.addEventListener('input', () => { state.searchQuery = el.searchInput.value; applyFilters(); }); + $('#btn-regex-toggle').addEventListener('click', () => { state.regexMode = !state.regexMode; $('#btn-regex-toggle').classList.toggle('active', state.regexMode); updateStatusBar(); if (state.searchQuery) applyFilters(); }); + $('#btn-case-toggle').addEventListener('click', () => { state.caseSensitive = !state.caseSensitive; $('#btn-case-toggle').classList.toggle('active', state.caseSensitive); if (state.searchQuery) applyFilters(); }); + + el.levelFilter.addEventListener('change', () => { state.activeLevel = el.levelFilter.value; applyFilters(); }); + el.sourceFilter.addEventListener('change', () => selectSource(el.sourceFilter.value)); + + $('#btn-theme').addEventListener('click', toggleTheme); + $('#btn-export').addEventListener('click', () => showModal(el.exportModal)); + $('#close-export').addEventListener('click', () => hideModal(el.exportModal)); + $('#export-txt').addEventListener('click', () => { const e = $('#export-filtered-only').checked ? state.filteredEntries : state.allEntries; downloadFile(e.map(x => x.line).join('\n'), 'logs.txt'); hideModal(el.exportModal); }); + $('#export-json').addEventListener('click', () => { const e = $('#export-filtered-only').checked ? state.filteredEntries : state.allEntries; downloadFile(JSON.stringify(e, null, 2), 'logs.json'); hideModal(el.exportModal); }); + + $('#btn-toggle-sidebar').addEventListener('click', () => { el.sidebar.classList.toggle('collapsed'); $('#btn-toggle-sidebar').textContent = el.sidebar.classList.contains('collapsed') ? '▶' : '◀'; }); + $('#btn-scroll-top').addEventListener('click', scrollTop); + $('#btn-scroll-bottom').addEventListener('click', scrollBottom); + $('#btn-clear-bookmarks').addEventListener('click', () => { state.bookmarks.clear(); renderLogs(); }); + + $('#close-goto').addEventListener('click', () => hideModal(el.gotoModal)); + $('#goto-btn').addEventListener('click', () => { const n = parseInt(el.gotoInput.value, 10); if (n > 0) { const t = el.logOutput.querySelector(`[data-line-number="${n}"]`); if (t) { t.scrollIntoView({ behavior: 'smooth', block: 'center' }); t.style.background = 'var(--highlight-bg)'; setTimeout(() => t.style.background = '', 2000); } hideModal(el.gotoModal); } }); + if (el.gotoInput) el.gotoInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') $('#goto-btn').click(); }); + + $$('.modal-overlay').forEach((o) => o.addEventListener('click', (e) => { if (e.target === o) hideModal(o); })); + + // Keyboard + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { $$('.modal-overlay').forEach((m) => hideModal(m)); if (state.searchQuery) { state.searchQuery = ''; el.searchInput.value = ''; applyFilters(); } hideCtx(); return; } + if (e.ctrlKey && e.key === 'f') { e.preventDefault(); el.searchInput.focus(); el.searchInput.select(); } + if (e.ctrlKey && e.shiftKey && e.key === 'F') { e.preventDefault(); state.regexMode = true; $('#btn-regex-toggle').classList.add('active'); updateStatusBar(); el.searchInput.focus(); } + if (e.ctrlKey && e.key === 'g') { e.preventDefault(); showModal(el.gotoModal); el.gotoInput.value = ''; } + if (e.ctrlKey && e.shiftKey && e.key === 'T') { e.preventDefault(); toggleTheme(); } + if (e.ctrlKey && e.key === '=') { e.preventDefault(); zoomIn(); } + if (e.ctrlKey && e.key === '-') { e.preventDefault(); zoomOut(); } + if (e.ctrlKey && e.key === '0') { e.preventDefault(); zoomReset(); } + if (e.key === 'Home' && !e.target.matches('input')) { e.preventDefault(); scrollTop(); } + if (e.key === 'End' && !e.target.matches('input')) { e.preventDefault(); scrollBottom(); } + }); + } + + init(); +})(); diff --git a/src/web/collector-app.js b/src/web/collector-app.js new file mode 100644 index 0000000..80c3d37 --- /dev/null +++ b/src/web/collector-app.js @@ -0,0 +1,320 @@ +// ============================================ +// Log Collector - Web UI +// ============================================ +(function () { + 'use strict'; + + const state = { + streams: [], + activeStream: null, + config: null, + liveFeed: [], + maxFeedLines: 500, + paused: false, + lastLineCount: 0, + linesPerMinute: 0, + startTime: Date.now(), + theme: 'dark', + pollTimer: null, + rateHistory: [], + }; + + const $ = (s) => document.querySelector(s); + const $$ = (s) => document.querySelectorAll(s); + + // ---- Init ---- + async function init() { + await loadConfig(); + await loadStreams(); + setupTabs(); + setupEvents(); + startPolling(); + updateUptime(); + setInterval(updateUptime, 1000); + setInterval(calcRate, 5000); + } + + // ---- API ---- + async function api(path, opts = {}) { + const res = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...opts }); + return res.json(); + } + + // ---- Config ---- + async function loadConfig() { + try { + state.config = await api('/collector/config'); + applyConfigToUI(); + } catch (e) { console.error('Config load error:', e); } + } + + function applyConfigToUI() { + const c = state.config; + if (!c) return; + $('#cfg-storage-dir').value = c.storageDir || './collector-data'; + $('#cfg-max-streams').value = c.maxStreams || 50; + $('#cfg-max-lines').value = c.maxLinesPerStream || 100000; + $('#cfg-persist').checked = c.persistToDisk !== false; + $('#cfg-udp-enabled').checked = c.udpEnabled !== false; + $('#cfg-udp-port').value = c.udpPort || 5140; + $('#udp-port').textContent = c.udpPort || 5140; + if (c.rotation) { + $('#cfg-rot-size').value = c.rotation.maxSizeBytes || 5242880; + $('#cfg-rot-age').value = c.rotation.maxAgeMs || 600000; + $('#cfg-rot-compression').value = c.rotation.compression || 'gzip'; + $('#cfg-rot-ext').value = c.rotation.fileExtension || '.log'; + } + renderForwardTargets(); + } + + // ---- Streams ---- + async function loadStreams() { + try { + state.streams = await api('/collector/streams'); + renderStreams(); + updateStats(); + } catch (e) { console.error('Stream load error:', e); } + } + + function renderStreams() { + const list = $('#stream-list'); + list.innerHTML = ''; + $('#no-streams').style.display = state.streams.length === 0 ? 'block' : 'none'; + + for (const s of state.streams) { + const card = document.createElement('div'); + card.className = 'stream-card' + (state.activeStream === s.id ? ' active' : ''); + const ago = s.lastReceived ? timeSince(new Date(s.lastReceived)) : 'never'; + card.innerHTML = ` +
${s.name || s.id}
+
+ ${s.lineCount} lines + ${ago} + ${fmtBytes(s.currentFileSize || 0)} +
`; + card.addEventListener('click', () => selectStream(s.id)); + list.appendChild(card); + } + } + + function selectStream(id) { + state.activeStream = state.activeStream === id ? null : id; + $('#live-stream-label').textContent = state.activeStream ? `Stream: ${state.activeStream}` : 'All Streams'; + renderStreams(); + } + + function updateStats() { + const total = state.streams.reduce((sum, s) => sum + s.lineCount, 0); + $('#stat-streams').textContent = state.streams.length; + $('#stat-lines').textContent = total > 9999 ? Math.floor(total / 1000) + 'k' : total; + $('#stat-rate').textContent = state.linesPerMinute; + } + + function calcRate() { + const total = state.streams.reduce((sum, s) => sum + s.lineCount, 0); + const diff = total - state.lastLineCount; + state.lastLineCount = total; + // Lines per minute (measured over 5s intervals) + state.linesPerMinute = Math.round(diff * 12); + updateStats(); + } + + // ---- Live Feed ---- + async function pollLiveFeed() { + if (state.paused) return; + try { + const streams = await api('/collector/streams'); + const oldStreams = new Map(state.streams.map(s => [s.id, s])); + state.streams = streams; + + for (const s of streams) { + const old = oldStreams.get(s.id); + if (old && s.lineCount > old.lineCount) { + // New lines arrived + const newCount = s.lineCount - old.lineCount; + const tail = Math.min(newCount, 20); + try { + const data = await api(`/collector/stream/${s.id}?tail=${tail}`); + if (data.lines) { + for (const line of data.lines) { + if (state.activeStream && state.activeStream !== s.id) continue; + addLiveLine(s.id, line); + } + } + } catch { } + } + } + + renderStreams(); + updateStats(); + } catch { } + } + + function addLiveLine(streamId, line) { + const feed = $('#live-feed'); + const div = document.createElement('div'); + div.className = 'live-line new'; + + // Detect level + const upper = line.toUpperCase(); + if (/\bERROR\b|\bFATAL\b/.test(upper)) div.classList.add('level-error'); + else if (/\bWARN\b/.test(upper)) div.classList.add('level-warn'); + else if (/\bINFO\b/.test(upper)) div.classList.add('level-info'); + + const prefix = document.createElement('span'); + prefix.style.cssText = 'color:var(--text-muted);margin-right:8px;font-size:10px;'; + prefix.textContent = `[${streamId}]`; + div.appendChild(prefix); + div.appendChild(document.createTextNode(line)); + + feed.appendChild(div); + state.liveFeed.push(div); + + // Remove "new" highlight after 3s + setTimeout(() => div.classList.remove('new'), 3000); + + // Limit feed size + while (state.liveFeed.length > state.maxFeedLines) { + const old = state.liveFeed.shift(); + if (old && old.parentNode) old.parentNode.removeChild(old); + } + + // Auto-scroll + feed.scrollTop = feed.scrollHeight; + } + + function startPolling() { + state.pollTimer = setInterval(pollLiveFeed, 1500); + } + + // ---- Forward Targets ---- + function renderForwardTargets() { + const list = $('#forward-list'); + const targets = state.config?.forwardTargets || []; + list.innerHTML = ''; + $('#no-forwards').style.display = targets.length === 0 ? 'block' : 'none'; + + for (let i = 0; i < targets.length; i++) { + const t = targets[i]; + const div = document.createElement('div'); + div.className = 'forward-target'; + div.innerHTML = ` +
${t.type.toUpperCase()} → ${t.url || t.path || t.host + ':' + t.port || 'stdout'}
+ `; + div.querySelector('button').addEventListener('click', () => removeForwardTarget(i)); + list.appendChild(div); + } + } + + function removeForwardTarget(idx) { + if (!state.config) return; + state.config.forwardTargets.splice(idx, 1); + renderForwardTargets(); + } + + // ---- Tabs ---- + function setupTabs() { + $$('.tab').forEach(tab => { + tab.addEventListener('click', () => { + $$('.tab').forEach(t => t.classList.remove('active')); + $$('.tab-content').forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + $(`#tab-${tab.dataset.tab}`).classList.add('active'); + }); + }); + } + + // ---- Events ---- + function setupEvents() { + $('#btn-theme').addEventListener('click', () => { + state.theme = state.theme === 'dark' ? 'light' : 'dark'; + document.body.className = `theme-${state.theme}`; + $('#btn-theme').textContent = state.theme === 'dark' ? '🌙' : '☀️'; + }); + + $('#btn-clear-feed').addEventListener('click', () => { + $('#live-feed').innerHTML = ''; + state.liveFeed = []; + }); + + $('#btn-pause-feed').addEventListener('click', () => { + state.paused = !state.paused; + $('#btn-pause-feed').textContent = state.paused ? '▶ Resume' : '⏸ Pause'; + }); + + $('#btn-save-config').addEventListener('click', async () => { + const config = { + collector: { + storageDir: $('#cfg-storage-dir').value, + maxStreams: parseInt($('#cfg-max-streams').value, 10), + maxLinesPerStream: parseInt($('#cfg-max-lines').value, 10), + persistToDisk: $('#cfg-persist').checked, + udp: { enabled: $('#cfg-udp-enabled').checked, port: parseInt($('#cfg-udp-port').value, 10) }, + rotation: { + maxSizeBytes: parseInt($('#cfg-rot-size').value, 10), + maxAgeMs: parseInt($('#cfg-rot-age').value, 10), + compression: $('#cfg-rot-compression').value, + fileExtension: $('#cfg-rot-ext').value, + }, + forwardTargets: state.config?.forwardTargets || [], + }, + }; + try { + await api('/api/config/save', { method: 'POST', body: JSON.stringify(config) }); + const status = $('#save-status'); + status.style.display = 'inline'; + setTimeout(() => status.style.display = 'none', 3000); + } catch (e) { alert('Save failed: ' + e.message); } + }); + + $('#btn-add-forward').addEventListener('click', () => { + if (!state.config) state.config = { forwardTargets: [] }; + if (!state.config.forwardTargets) state.config.forwardTargets = []; + + const target = { + type: $('#fwd-type').value, + format: $('#fwd-format').value, + batchSize: parseInt($('#fwd-batch').value, 10) || 100, + flushIntervalMs: parseInt($('#fwd-flush').value, 10) || 5000, + }; + + const urlVal = $('#fwd-url').value.trim(); + const portVal = $('#fwd-port').value.trim(); + + if (target.type === 'http') target.url = urlVal; + else if (target.type === 'file') target.path = urlVal; + else if (target.type === 'udp') { target.host = urlVal; target.port = parseInt(portVal, 10); } + + state.config.forwardTargets.push(target); + renderForwardTargets(); + $('#fwd-url').value = ''; + $('#fwd-port').value = ''; + }); + } + + // ---- Helpers ---- + function updateUptime() { + const s = Math.floor((Date.now() - state.startTime) / 1000); + const m = Math.floor(s / 60); + const h = Math.floor(m / 60); + $('#uptime').textContent = h > 0 ? `Uptime: ${h}h ${m % 60}m` : m > 0 ? `Uptime: ${m}m ${s % 60}s` : `Uptime: ${s}s`; + } + + function timeSince(date) { + const s = Math.floor((Date.now() - date.getTime()) / 1000); + if (s < 5) return 'just now'; + if (s < 60) return s + 's ago'; + const m = Math.floor(s / 60); + if (m < 60) return m + 'm ago'; + return Math.floor(m / 60) + 'h ago'; + } + + function fmtBytes(b) { + if (!b) return '0 B'; + const u = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(b) / Math.log(1024)); + return (b / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + u[i]; + } + + init(); +})(); diff --git a/src/web/collector.html b/src/web/collector.html new file mode 100644 index 0000000..04519a2 --- /dev/null +++ b/src/web/collector.html @@ -0,0 +1,205 @@ + + + + + + Log Collector + + + + +
+
+

📡 Log Collector ACTIVE

+
+ Uptime: 0s + +
+
+ +
+ + + + +
+
+
🔴 Live Feed
+
⚙️ Configuration
+
🔀 Forwarding
+
+ + +
+
+ All Streams +
+ + +
+
+
+
+ + +
+
+
+

General

+
+
+
+
+
+
+
+
+
+ +
+

UDP Receiver

+
+
+
+
+
+ +
+

Log Rotation

+
+
+
+
+
+
+ + +
+
+
+
+ + + +
+
+ + +
+
+

Active Forward Targets

+
+
No forward targets configured.
+ +

Add Target

+
+
+ + +
+
+ +
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ + + + diff --git a/src/web/index.html b/src/web/index.html new file mode 100644 index 0000000..6802a2e --- /dev/null +++ b/src/web/index.html @@ -0,0 +1,218 @@ + + + + + + Log Viewer + + + + + + + +
+ + + + +
+
+
+ All Sources + +
+
+ + + +
+
+
+
+

📋 Log Viewer

+

Logs are loaded from the server's configured sources.

+
+

Press Ctrl+F to search, Ctrl+T for tail mode

+
+
+
+
+
+ + +
+
+ Connecting... +
+
+ TAIL + WRAP + REGEX + 100% +
+
+ + + + + + + + + + + + + + + + diff --git a/src/web/monitor-app.js b/src/web/monitor-app.js new file mode 100644 index 0000000..36b448f --- /dev/null +++ b/src/web/monitor-app.js @@ -0,0 +1,318 @@ +// ============================================ +// Log Monitor - Web UI +// ============================================ +(function () { + 'use strict'; + + const state = { rules: [], results: [], activeRule: null, editNotifications: [], theme: 'dark' }; + const $ = (s) => document.querySelector(s); + const $$ = (s) => document.querySelectorAll(s); + + async function api(path, opts = {}) { + const res = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...opts }); + return res.json(); + } + + async function init() { + await loadRules(); + await loadResults(); + setupEvents(); + setInterval(loadResults, 10000); + setInterval(loadRules, 30000); + } + + async function loadRules() { + try { state.rules = await api('/api/monitor/rules'); } catch { state.rules = []; } + renderRules(); + } + + async function loadResults() { + try { state.results = await api('/api/monitor/results'); } catch { state.results = []; } + renderResults(); + $('#scan-status').textContent = `${state.rules.filter(r => r.enabled).length} active rule(s) · ${state.results.length} alert(s)`; + } + + function renderRules() { + const list = $('#rule-list'); + list.innerHTML = ''; + $('#no-rules').style.display = state.rules.length === 0 ? 'block' : 'none'; + for (const rule of state.rules) { + const card = document.createElement('div'); + card.className = 'rule-card' + (state.activeRule === rule.id ? ' active' : ''); + card.innerHTML = ` +
${rule.name}
+
${rule.sources.length} source(s) · ${rule.matchCount || 0} matches · ${rule.timeRange?.type || 'all'}
+
${rule.patterns.join(' | ')}
`; + card.addEventListener('click', () => editRule(rule)); + list.appendChild(card); + } + } + + function renderResults() { + const list = $('#result-list'); + list.innerHTML = ''; + $('#no-results').style.display = state.results.length === 0 ? 'block' : 'none'; + for (const r of state.results.slice(-10).reverse()) { + const card = document.createElement('div'); + card.className = 'result-card'; + const time = new Date(r.timestamp).toLocaleString(); + const lines = r.matchedLines || []; + const preview = lines.slice(0, 8).map(l => esc(l)).join('\n'); + card.innerHTML = ` +
+ ${r.ruleName} + 🚨 ${r.totalMatches} matches +
+
+ 📅 ${time} · 📁 ${(r.sources||[]).map(s=>s.split(/[/\\\\]/).pop()).join(', ')} +
+
${preview}
+ ${lines.length > 8 ? '
... and ' + (lines.length - 8) + ' more lines
' : ''} + `; + card.style.cursor = 'pointer'; + card.addEventListener('click', () => showFullAlert(r)); + list.appendChild(card); + } + } + + function showFullAlert(result) { + const main = $('#main-panel'); + const lines = result.matchedLines || []; + const time = new Date(result.timestamp).toLocaleString(); + main.innerHTML = ` +
+ +

🚨 ${esc(result.ruleName)}

+
+
📅 Triggered: ${time}
+
📁 Sources: ${(result.sources||[]).join(', ')}
+
📊 Total matches: ${result.totalMatches}
+
+
+
+
Matched lines:
+
${lines.map((l,i) => `${String(i+1).padStart(3)}${colorLine(esc(l))}`).join('\n')}
+
+ `; + main.querySelector('#btn-back-alerts').addEventListener('click', () => { + main.innerHTML = ''; + main.appendChild(createWelcomeOrEditor()); + }); + } + + function colorLine(html) { + if (/\bERROR\b|\bFATAL\b|\bCRITICAL\b/i.test(html)) return '' + html + ''; + if (/\bWARN\b/i.test(html)) return '' + html + ''; + return html; + } + + function createWelcomeOrEditor() { + // Re-render the default state + const div = document.createElement('div'); + div.id = 'welcome-panel'; + div.className = 'empty-state'; + div.innerHTML = '

🔍 Log Monitor

Select a rule from the sidebar or click an alert to view details.

'; + return div; + } + + function editRule(rule) { + state.activeRule = rule.id; + state.editNotifications = [...(rule.notifications || [])]; + $('#welcome-panel').style.display = 'none'; + $('#rule-editor').style.display = 'block'; + $('#rule-name').value = rule.name; + $('#rule-enabled').value = String(rule.enabled); + $('#rule-sources').value = (rule.sources || []).join('\n'); + $('#rule-patterns').value = (rule.patterns || []).join('\n'); + $('#rule-logic').value = rule.patternLogic || 'any'; + $('#rule-max-lines').value = rule.maxLinesInMessage || 20; + $('#rule-cooldown').value = rule.cooldownMs || 300000; + $('#rule-time-type').value = rule.timeRange?.type || 'week'; + $('#rule-time-hours').value = rule.timeRange?.hours || 24; + renderNotifications(); + renderRules(); + } + + function newRule() { + state.activeRule = 'new-' + Date.now(); + state.editNotifications = []; + $('#welcome-panel').style.display = 'none'; + $('#rule-editor').style.display = 'block'; + $('#rule-name').value = ''; + $('#rule-enabled').value = 'true'; + $('#rule-sources').value = ''; + $('#rule-patterns').value = ''; + $('#rule-logic').value = 'any'; + $('#rule-max-lines').value = '20'; + $('#rule-cooldown').value = '300000'; + $('#rule-time-type').value = 'week'; + $('#rule-time-hours').value = '24'; + renderNotifications(); + } + + function renderNotifications() { + const list = $('#notification-list'); + list.innerHTML = ''; + for (let i = 0; i < state.editNotifications.length; i++) { + const n = state.editNotifications[i]; + const card = document.createElement('div'); + card.className = 'notification-card'; + card.innerHTML = `
${n.type.toUpperCase()} ${n.url || n.chatId || n.to || ''}
`; + card.querySelector('button').addEventListener('click', () => { state.editNotifications.splice(i, 1); renderNotifications(); }); + list.appendChild(card); + } + } + + function collectRuleFromForm() { + return { + id: state.activeRule.startsWith('new-') ? state.activeRule : state.activeRule, + name: $('#rule-name').value.trim() || 'Unnamed Rule', + enabled: $('#rule-enabled').value === 'true', + sources: $('#rule-sources').value.split('\n').map(s => s.trim()).filter(s => s), + patterns: $('#rule-patterns').value.split('\n').map(s => s.trim()).filter(s => s), + patternLogic: $('#rule-logic').value, + timeRange: { type: $('#rule-time-type').value, hours: parseInt($('#rule-time-hours').value, 10) }, + maxLinesInMessage: parseInt($('#rule-max-lines').value, 10) || 20, + cooldownMs: parseInt($('#rule-cooldown').value, 10) || 300000, + schedule: { mode: $('#rule-schedule').value, value: $('#rule-schedule-value').value.trim(), timezone: $('#rule-schedule-tz').value.trim() }, + notifications: state.editNotifications, + matchCount: 0, + lastTriggered: null, + }; + } + + function setupEvents() { + $('#btn-new-rule').addEventListener('click', newRule); + + $('#btn-save-rule').addEventListener('click', async () => { + const rule = collectRuleFromForm(); + if (state.activeRule.startsWith('new-')) { + await api('/api/monitor/rules', { method: 'POST', body: JSON.stringify(rule) }); + } else { + await api(`/api/monitor/rule/${state.activeRule}`, { method: 'PUT', body: JSON.stringify(rule) }); + } + await loadRules(); + }); + + $('#btn-test-rule').addEventListener('click', async () => { + const rule = collectRuleFromForm(); + // Save first if new + if (state.activeRule.startsWith('new-')) { + const res = await api('/api/monitor/rules', { method: 'POST', body: JSON.stringify(rule) }); + if (res.rule) state.activeRule = res.rule.id; + await loadRules(); + } + // Trigger + const result = await api(`/api/monitor/trigger/${state.activeRule}`, { method: 'POST' }); + const div = $('#test-result'); + div.style.display = 'block'; + if (result && result.totalMatches > 0) { + div.innerHTML = `
Test Result${result.totalMatches} matches
${result.matchedLines.slice(0, 10).map(l => esc(l)).join('
')}
`; + } else { + div.innerHTML = `
No matches found.
`; + } + await loadResults(); + }); + + $('#btn-delete-rule').addEventListener('click', async () => { + if (!state.activeRule || state.activeRule.startsWith('new-')) return; + if (!confirm('Delete this rule?')) return; + await api(`/api/monitor/rule/${state.activeRule}`, { method: 'DELETE' }); + state.activeRule = null; + $('#rule-editor').style.display = 'none'; + $('#welcome-panel').style.display = 'block'; + await loadRules(); + }); + + $('#btn-add-notif').addEventListener('click', () => { + const type = $('#notif-type').value; + const urlVal = $('#notif-url').value.trim(); + const extra = $('#notif-extra').value.trim(); + const notif = { type }; + if (type === 'webhook') notif.url = urlVal; + else if (type === 'teams') notif.url = urlVal; + else if (type === 'telegram') { notif.botToken = urlVal; notif.chatId = extra; } + else if (type === 'email') { + notif.to = extra; + notif.smtpHost = urlVal; + notif.smtpPort = parseInt($('#notif-extra2').value || '587', 10); + notif.smtpUser = $('#notif-smtp-user').value.trim(); + notif.smtpPass = $('#notif-smtp-pass').value.trim(); + } + state.editNotifications.push(notif); + renderNotifications(); + $('#notif-url').value = ''; + $('#notif-extra').value = ''; + }); + + // Dynamic notification form fields + $('#notif-type').addEventListener('change', () => { + const type = $('#notif-type').value; + const f1Label = $('#notif-field-1-label'); + const f2Label = $('#notif-field-2-label'); + const f3Wrap = $('#notif-field-3-wrap'); + const smtpRow = $('#notif-smtp-row'); + const urlInput = $('#notif-url'); + const extraInput = $('#notif-extra'); + + f3Wrap.style.display = 'none'; + smtpRow.style.display = 'none'; + + switch (type) { + case 'webhook': + f1Label.textContent = 'Webhook URL'; + urlInput.placeholder = 'https://hooks.slack.com/services/...'; + f2Label.textContent = '(unused)'; + extraInput.placeholder = ''; + break; + case 'telegram': + f1Label.textContent = 'Bot Token'; + urlInput.placeholder = '123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11'; + f2Label.textContent = 'Chat ID'; + extraInput.placeholder = '-1001234567890'; + break; + case 'teams': + f1Label.textContent = 'Teams Webhook URL'; + urlInput.placeholder = 'https://outlook.office.com/webhook/...'; + f2Label.textContent = '(unused)'; + extraInput.placeholder = ''; + break; + case 'email': + f1Label.textContent = 'SMTP Host'; + urlInput.placeholder = 'smtp.gmail.com'; + f2Label.textContent = 'To (email)'; + extraInput.placeholder = 'admin@company.com'; + f3Wrap.style.display = 'block'; + smtpRow.style.display = 'grid'; + break; + } + }); + + // Test notification button + $('#btn-test-notif').addEventListener('click', async () => { + const type = $('#notif-type').value; + const notif = { type }; + const urlVal = $('#notif-url').value.trim(); + const extra = $('#notif-extra').value.trim(); + if (type === 'webhook') notif.url = urlVal; + else if (type === 'teams') notif.url = urlVal; + else if (type === 'telegram') { notif.botToken = urlVal; notif.chatId = extra; } + else if (type === 'email') { notif.to = extra; notif.smtpHost = urlVal; notif.smtpPort = parseInt($('#notif-extra2').value || '587', 10); notif.smtpUser = $('#notif-smtp-user').value.trim(); notif.smtpPass = $('#notif-smtp-pass').value.trim(); } + + try { + const res = await api('/api/monitor/test-notification', { method: 'POST', body: JSON.stringify(notif) }); + alert(res.ok ? '✅ Test notification sent!' : '❌ Failed: ' + (res.error || 'unknown')); + } catch (e) { alert('❌ Error: ' + e.message); } + }); + + $('#btn-theme').addEventListener('click', () => { + state.theme = state.theme === 'dark' ? 'light' : 'dark'; + document.body.className = `theme-${state.theme}`; + $('#btn-theme').textContent = state.theme === 'dark' ? '🌙' : '☀️'; + }); + } + + function esc(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; } + + init(); +})(); diff --git a/src/web/monitor.html b/src/web/monitor.html new file mode 100644 index 0000000..11cb47b --- /dev/null +++ b/src/web/monitor.html @@ -0,0 +1,182 @@ + + + + + + Log Monitor + + + + +
+
+

🔍 Log Monitor

+
+ Scanning... + +
+
+ +
+ + +
+
+

🔍 Log Monitor

+

Create rules to scan log files for patterns and get notified.

+

Click "+ New" to create your first rule.

+
+ + + +
+
+
+ + + + diff --git a/src/web/styles.css b/src/web/styles.css new file mode 100644 index 0000000..0a8f428 --- /dev/null +++ b/src/web/styles.css @@ -0,0 +1,163 @@ +/* ============================================ + Log Viewer - Styles + ============================================ */ + +:root { + --bg-primary: #1e1e2e; + --bg-secondary: #181825; + --bg-tertiary: #313244; + --bg-hover: #45475a; + --bg-active: #585b70; + --text-primary: #cdd6f4; + --text-secondary: #a6adc8; + --text-muted: #6c7086; + --border-color: #45475a; + --accent: #89b4fa; + --accent-hover: #74c7ec; + --error: #f38ba8; + --warn: #fab387; + --info: #89b4fa; + --debug: #a6adc8; + --fatal: #f38ba8; + --trace: #6c7086; + --success: #a6e3a1; + --highlight-bg: #f9e2af33; + --highlight-border: #f9e2af; + --bookmark-color: #f9e2af; + --scrollbar-bg: #313244; + --scrollbar-thumb: #585b70; + --shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + --font-mono: 'Cascadia Code', 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; + --font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} +.theme-light { + --bg-primary: #eff1f5; --bg-secondary: #e6e9ef; --bg-tertiary: #ccd0da; + --bg-hover: #bcc0cc; --bg-active: #acb0be; --text-primary: #4c4f69; + --text-secondary: #5c5f77; --text-muted: #8c8fa1; --border-color: #ccd0da; + --accent: #1e66f5; --accent-hover: #2a6ef5; --error: #d20f39; --warn: #df8e1d; + --info: #1e66f5; --debug: #8c8fa1; --fatal: #d20f39; --trace: #8c8fa1; + --success: #40a02b; --highlight-bg: #df8e1d33; --highlight-border: #df8e1d; + --bookmark-color: #df8e1d; --scrollbar-bg: #ccd0da; --scrollbar-thumb: #acb0be; + --shadow: 0 2px 8px rgba(0,0,0,0.1); +} +*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} +html,body{height:100%;overflow:hidden} +body{font-family:var(--font-ui);font-size:13px;color:var(--text-primary);background:var(--bg-primary);display:flex;flex-direction:column;transition:background-color .2s,color .2s} +::-webkit-scrollbar{width:10px;height:10px} +::-webkit-scrollbar-track{background:var(--scrollbar-bg)} +::-webkit-scrollbar-thumb{background:var(--scrollbar-thumb);border-radius:5px} +::-webkit-scrollbar-thumb:hover{background:var(--bg-active)} +.toolbar{display:flex;align-items:center;justify-content:space-between;padding:6px 12px;background:var(--bg-secondary);border-bottom:1px solid var(--border-color);gap:12px;flex-shrink:0;user-select:none} +.toolbar-left,.toolbar-center,.toolbar-right{display:flex;align-items:center;gap:6px} +.toolbar-center{flex:1;max-width:500px} +.toolbar-btn{display:flex;align-items:center;gap:4px;padding:5px 10px;background:var(--bg-tertiary);color:var(--text-primary);border:1px solid var(--border-color);border-radius:4px;cursor:pointer;font-size:12px;font-family:var(--font-ui);transition:all .15s;white-space:nowrap} +.toolbar-btn:hover{background:var(--bg-hover)} +.toolbar-btn:active{background:var(--bg-active)} +.toolbar-btn.active{background:var(--accent);color:#1e1e2e;border-color:var(--accent)} +.toolbar-separator{width:1px;height:24px;background:var(--border-color);margin:0 4px} +.toolbar-select{padding:5px 8px;background:var(--bg-tertiary);color:var(--text-primary);border:1px solid var(--border-color);border-radius:4px;font-size:12px;font-family:var(--font-ui);cursor:pointer;max-width:160px} +.toolbar-select:focus{outline:2px solid var(--accent);outline-offset:-1px} +.search-box{display:flex;align-items:center;background:var(--bg-tertiary);border:1px solid var(--border-color);border-radius:4px;overflow:hidden;width:100%;transition:border-color .15s} +.search-box:focus-within{border-color:var(--accent)} +#search-input{flex:1;padding:5px 10px;background:transparent;color:var(--text-primary);border:none;font-size:12px;font-family:var(--font-ui);outline:none;min-width:0} +#search-input::placeholder{color:var(--text-muted)} +.search-toggle{padding:4px 8px;background:transparent;color:var(--text-muted);border:none;border-left:1px solid var(--border-color);cursor:pointer;font-size:11px;font-family:var(--font-mono);font-weight:bold;transition:all .15s} +.search-toggle:hover{color:var(--text-primary);background:var(--bg-hover)} +.search-toggle.active{color:var(--accent);background:var(--bg-hover)} +.search-count{padding:0 8px;font-size:11px;color:var(--text-muted);white-space:nowrap} +.main-content{display:flex;flex:1;overflow:hidden} +.sidebar{width:260px;min-width:200px;background:var(--bg-secondary);border-right:1px solid var(--border-color);display:flex;flex-direction:column;transition:width .2s,min-width .2s;flex-shrink:0} +.sidebar.collapsed{width:0;min-width:0;overflow:hidden;border-right:none} +.sidebar-header{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border-bottom:1px solid var(--border-color)} +.sidebar-header h2{font-size:13px;font-weight:600;color:var(--text-secondary);text-transform:uppercase;letter-spacing:.5px} +.sidebar-toggle{background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:12px;padding:2px 6px;border-radius:3px;transition:all .15s} +.sidebar-toggle:hover{background:var(--bg-hover);color:var(--text-primary)} +.source-list{flex:1;overflow-y:auto;padding:6px} +.source-item{display:flex;align-items:center;padding:8px 10px;border-radius:4px;cursor:pointer;transition:background .15s;gap:8px;margin-bottom:2px} +.source-item:hover{background:var(--bg-hover)} +.source-item.active{background:var(--bg-tertiary);border-left:3px solid var(--accent);padding-left:7px} +.source-item .source-icon{font-size:14px;flex-shrink:0} +.source-item .source-details{flex:1;min-width:0} +.source-item .source-name{font-size:12px;font-weight:500;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.source-item .source-meta{font-size:10px;color:var(--text-muted);margin-top:2px} +.source-item .source-remove{opacity:0;background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:14px;padding:2px 4px;border-radius:3px;transition:all .15s} +.source-item:hover .source-remove{opacity:1} +.source-item .source-remove:hover{color:var(--error);background:var(--bg-hover)} +.source-item .source-status{width:8px;height:8px;border-radius:50%;flex-shrink:0} +.source-item .source-status.online{background:var(--success)} +.source-item .source-status.offline{background:var(--error)} +.empty-state{padding:20px;text-align:center;color:var(--text-muted);font-size:12px;line-height:1.6} +.empty-state code{background:var(--bg-tertiary);padding:2px 6px;border-radius:3px;font-family:var(--font-mono);font-size:11px} +.sidebar-footer{padding:8px 12px;border-top:1px solid var(--border-color)} +.stats{display:flex;justify-content:space-between;font-size:11px;color:var(--text-muted)} +.log-container{flex:1;display:flex;flex-direction:column;overflow:hidden} +.log-header{display:flex;align-items:center;justify-content:space-between;padding:6px 12px;background:var(--bg-secondary);border-bottom:1px solid var(--border-color);flex-shrink:0} +.log-header-info{display:flex;align-items:center;gap:10px} +#current-source-name{font-weight:600;font-size:13px} +.source-info{font-size:11px;color:var(--text-muted)} +.log-header-actions{display:flex;gap:6px} +.small-btn{padding:3px 8px;background:var(--bg-tertiary);color:var(--text-secondary);border:1px solid var(--border-color);border-radius:3px;cursor:pointer;font-size:11px;font-family:var(--font-ui);transition:all .15s} +.small-btn:hover{background:var(--bg-hover);color:var(--text-primary)} +.log-output{flex:1;overflow-y:auto;overflow-x:auto;padding:0;font-family:var(--font-mono);font-size:12px;line-height:1.6;background:var(--bg-primary);outline:none} +.log-output.word-wrap{overflow-x:hidden} +.log-output.word-wrap .log-line-content{white-space:pre-wrap;word-break:break-all} +.log-line{display:flex;padding:0 12px;min-height:20px;border-bottom:1px solid transparent;transition:background .1s} +.log-line:hover{background:var(--bg-tertiary)} +.log-line.bookmarked{background:var(--highlight-bg);border-left:3px solid var(--bookmark-color)} +.log-line-number{min-width:50px;padding-right:12px;text-align:right;color:var(--text-muted);user-select:none;flex-shrink:0;cursor:pointer;font-size:11px;line-height:20px} +.log-line-number:hover{color:var(--accent)} +.log-line-source{min-width:80px;max-width:120px;padding-right:8px;color:var(--text-muted);font-size:10px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex-shrink:0;line-height:20px} +.log-line-timestamp{min-width:160px;padding-right:8px;color:var(--text-secondary);font-size:11px;flex-shrink:0;line-height:20px} +.log-line-level{min-width:60px;padding-right:8px;font-weight:600;font-size:11px;flex-shrink:0;line-height:20px} +.log-line-level.level-error,.log-line-level.level-fatal{color:var(--error)} +.log-line-level.level-warn{color:var(--warn)} +.log-line-level.level-info{color:var(--info)} +.log-line-level.level-debug{color:var(--debug)} +.log-line-level.level-trace{color:var(--trace)} +.log-line-content{flex:1;white-space:pre;min-width:0;line-height:20px} +.log-line-content .highlight{background:var(--highlight-bg);border-bottom:1px solid var(--highlight-border);border-radius:2px;padding:0 1px} +.log-line.line-error{color:var(--error)} +.log-line.line-fatal{color:var(--fatal);font-weight:600} +.log-line.line-warn{color:var(--warn)} +.welcome-message{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;color:var(--text-muted);text-align:center;padding:40px} +.welcome-message h2{font-size:20px;color:var(--text-secondary);margin-bottom:16px} +.welcome-message p{margin-bottom:8px} +.welcome-message code{background:var(--bg-tertiary);padding:2px 6px;border-radius:3px;font-family:var(--font-mono);font-size:12px} +.shortcut-hint{margin-top:20px;padding:12px 20px;background:var(--bg-tertiary);border-radius:6px} +kbd{display:inline-block;padding:2px 6px;background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:3px;font-family:var(--font-mono);font-size:11px;box-shadow:0 1px 0 var(--border-color)} +.statusbar{display:flex;align-items:center;justify-content:space-between;padding:3px 12px;background:var(--bg-secondary);border-top:1px solid var(--border-color);font-size:11px;color:var(--text-muted);flex-shrink:0} +.statusbar-left,.statusbar-right{display:flex;align-items:center;gap:12px} +.status-indicator{padding:1px 6px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase;opacity:.4;transition:opacity .15s} +.status-indicator.active{opacity:1;color:var(--accent)} +.modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1000} +.modal-overlay[hidden]{display:none} +.modal{background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:8px;box-shadow:var(--shadow);min-width:400px;max-width:600px;max-height:80vh;overflow-y:auto} +.modal-small{min-width:300px;max-width:400px} +.modal-header{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--border-color)} +.modal-header h2{font-size:16px;font-weight:600} +.modal-close{background:none;border:none;color:var(--text-muted);font-size:20px;cursor:pointer;padding:4px 8px;border-radius:4px;transition:all .15s} +.modal-close:hover{background:var(--bg-hover);color:var(--text-primary)} +.modal-body{padding:20px} +.export-options{display:flex;gap:10px;margin:16px 0} +.export-btn{flex:1;padding:10px;background:var(--bg-tertiary);color:var(--text-primary);border:1px solid var(--border-color);border-radius:6px;cursor:pointer;font-size:13px;font-family:var(--font-ui);transition:all .15s} +.export-btn:hover{background:var(--accent);color:#1e1e2e;border-color:var(--accent)} +.checkbox-label{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text-secondary);cursor:pointer} +.primary-btn{padding:6px 16px;background:var(--accent);color:#1e1e2e;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-family:var(--font-ui);font-weight:600;transition:all .15s} +.primary-btn:hover{background:var(--accent-hover)} +#goto-input{width:100%;padding:8px 12px;background:var(--bg-tertiary);color:var(--text-primary);border:1px solid var(--border-color);border-radius:4px;font-size:14px;font-family:var(--font-mono);margin-bottom:12px;outline:none} +#goto-input:focus{border-color:var(--accent)} +.context-menu{position:fixed;background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:6px;box-shadow:var(--shadow);padding:4px;z-index:2000;min-width:180px} +.context-menu-item{display:flex;align-items:center;gap:8px;padding:6px 12px;color:var(--text-primary);font-size:12px;cursor:pointer;border-radius:4px;transition:background .1s} +.context-menu-item:hover{background:var(--bg-hover)} +.context-menu-separator{height:1px;background:var(--border-color);margin:4px 0} +@media(max-width:900px){.sidebar{width:200px;min-width:160px}.toolbar-center{max-width:300px}} + +/* Live line highlight */ +.log-line.new-line { + animation: newLineFlash 2.5s ease-out; +} +@keyframes newLineFlash { + 0% { background: rgba(137,180,250,0.2); border-left: 3px solid var(--accent); } + 70% { background: rgba(137,180,250,0.05); } + 100% { background: transparent; border-left: 3px solid transparent; } +} diff --git a/test-all.js b/test-all.js new file mode 100644 index 0000000..e953500 --- /dev/null +++ b/test-all.js @@ -0,0 +1,306 @@ +#!/usr/bin/env node +// ============================================ +// Full Integration Test for LogViewer +// Tests: Collector, Viewer, Monitor, Self-Forward, Telegram +// ============================================ +const http = require('http'); + +const COLLECTOR = 'http://localhost:8081'; +const VIEWER = 'http://localhost:9090'; +const MONITOR = 'http://localhost:8082'; + +let passed = 0, failed = 0; + +function post(base, path, data) { + return new Promise((resolve, reject) => { + const payload = JSON.stringify(data); + const url = new URL(path, base); + const req = http.request({ + hostname: url.hostname, port: url.port, path: url.pathname + url.search, + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }, + }, (res) => { + let body = ''; + res.on('data', c => body += c); + res.on('end', () => { try { resolve(JSON.parse(body)); } catch { resolve(body); } }); + }); + req.on('error', reject); + req.write(payload); + req.end(); + }); +} + +function postText(base, path, text) { + return new Promise((resolve, reject) => { + const url = new URL(path, base); + const req = http.request({ + hostname: url.hostname, port: url.port, path: url.pathname, + method: 'POST', + headers: { 'Content-Type': 'text/plain', 'Content-Length': Buffer.byteLength(text) }, + }, (res) => { + let body = ''; + res.on('data', c => body += c); + res.on('end', () => { try { resolve(JSON.parse(body)); } catch { resolve(body); } }); + }); + req.on('error', reject); + req.write(text); + req.end(); + }); +} + +function get(base, path) { + return new Promise((resolve, reject) => { + const url = new URL(path, base); + http.get({ hostname: url.hostname, port: url.port, path: url.pathname + url.search }, (res) => { + let body = ''; + res.on('data', c => body += c); + res.on('end', () => { try { resolve(JSON.parse(body)); } catch { resolve(body); } }); + }).on('error', reject); + }); +} + +function del(base, path) { + return new Promise((resolve, reject) => { + const url = new URL(path, base); + const req = http.request({ hostname: url.hostname, port: url.port, path: url.pathname + url.search, method: 'DELETE' }, (res) => { + let body = ''; + res.on('data', c => body += c); + res.on('end', () => { try { resolve(JSON.parse(body)); } catch { resolve(body); } }); + }); + req.on('error', reject); + req.end(); + }); +} + +function check(name, condition) { + if (condition) { console.log(` ✅ ${name}`); passed++; } + else { console.log(` ❌ ${name}`); failed++; } +} + +function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } + +async function testCollector() { + console.log('\n ═══ COLLECTOR TESTS ═══\n'); + + // 1. Health check + const mode = await get(COLLECTOR, '/api/mode'); + check('Collector is running', mode && mode.mode === 'collector'); + check('Collector is active', mode && mode.hasCollector === true); + + // 2. Send plain text logs + const r1 = await postText(COLLECTOR, '/collect/test-plain', '2026-05-01T10:00:00Z INFO Test line 1\n2026-05-01T10:00:01Z ERROR Test line 2'); + check('Plain text ingest', r1 && r1.ok === true && r1.accepted === 2); + + // 3. Send JSON logs + const r2 = await post(COLLECTOR, '/collect/test-json/json', ['INFO json line 1', 'WARN json line 2', 'ERROR json line 3']); + check('JSON ingest', r2 && r2.ok === true && r2.accepted === 3); + + // 4. List streams + const streams = await get(COLLECTOR, '/collector/streams'); + check('Streams listed', Array.isArray(streams) && streams.length >= 2); + check('test-plain stream exists', streams.some(s => s.id === 'test-plain')); + check('test-json stream exists', streams.some(s => s.id === 'test-json')); + + // 5. Read stream + const data = await get(COLLECTOR, '/collector/stream/test-plain'); + check('Read stream', data && data.lines && data.lines.length === 2); + + // 6. Read with tail + const tail = await get(COLLECTOR, '/collector/stream/test-json?tail=1'); + check('Tail parameter works', tail && tail.lines && tail.lines.length === 1); + + // 7. Self-forward: send to stream-a, should appear in stream-all + // First we need to configure forward targets - this requires restart, so we test via direct ingest + const r3 = await postText(COLLECTOR, '/collect/stream-a', 'INFO from stream-a'); + check('Stream-a ingest', r3 && r3.ok === true); + + // 8. Delete stream + const delResult = await del(COLLECTOR, '/collector/stream/test-plain'); + check('Delete stream', delResult && delResult.ok === true); + const afterDel = await get(COLLECTOR, '/collector/streams'); + check('Stream removed', !afterDel.some(s => s.id === 'test-plain')); + + // 9. Collector config endpoint + const config = await get(COLLECTOR, '/collector/config'); + check('Config endpoint', config && config.storageDir && config.rotation); + + // 10. Bulk ingest + const lines = []; + for (let i = 0; i < 50; i++) lines.push(`2026-05-01T10:${String(i).padStart(2,'0')}:00Z INFO Bulk line ${i}`); + const r4 = await post(COLLECTOR, '/collect/bulk-test/json', lines); + check('Bulk ingest (50 lines)', r4 && r4.accepted === 50); +} + +async function testViewer() { + console.log('\n ═══ VIEWER TESTS ═══\n'); + + // 1. Health check + const mode = await get(VIEWER, '/api/mode'); + check('Viewer is running', mode && mode.mode === 'viewer'); + + // 2. HTML page served + const html = await new Promise((resolve, reject) => { + http.get(`${VIEWER}/`, (res) => { + let body = ''; + res.on('data', c => body += c); + res.on('end', () => resolve({ status: res.statusCode, body })); + }).on('error', reject); + }); + check('HTML page served (200)', html.status === 200); + check('HTML contains Log Viewer', html.body.includes('Log Viewer')); + + // 3. Sources listed + const sources = await get(VIEWER, '/api/sources'); + check('File sources listed', Array.isArray(sources) && sources.length > 0); + + // 4. Read logs + const logs = await get(VIEWER, '/api/logs'); + check('Logs readable', Array.isArray(logs) && logs.length > 0); + check('Log entries have structure', logs[0] && logs[0].line && logs[0].source); + + // 5. Read specific source + if (sources.length > 0) { + const src = sources[0].path; + const srcLogs = await get(VIEWER, `/api/logs?source=${encodeURIComponent(src)}`); + check('Source-specific logs', Array.isArray(srcLogs) && srcLogs.length > 0); + } + + // 6. Remote streams from collector + const remote = await get(VIEWER, '/api/remote/streams'); + check('Remote streams accessible', Array.isArray(remote)); + if (remote.length > 0) { + const stream = remote[0]; + const remoteData = await get(VIEWER, `/api/remote/stream/${stream.id}`); + check('Remote stream readable', Array.isArray(remoteData) && remoteData.length > 0); + } else { + check('Remote stream readable (skipped - no streams)', true); + } + + // 7. Config endpoint + const config = await get(VIEWER, '/api/config'); + check('Config endpoint', config && config.theme); + + // 8. Add source + const addResult = await post(VIEWER, '/api/sources', { path: 'sample-logs/security-audit.log' }); + check('Add source', Array.isArray(addResult)); +} + +async function testMonitor() { + console.log('\n ═══ MONITOR TESTS ═══\n'); + + // 1. Health check + const mode = await get(MONITOR, '/api/mode'); + check('Monitor is running', mode && mode.mode === 'monitor'); + + // 2. HTML page served (monitor UI) + const html = await new Promise((resolve, reject) => { + http.get(`${MONITOR}/`, (res) => { + let body = ''; + res.on('data', c => body += c); + res.on('end', () => resolve({ status: res.statusCode, body })); + }).on('error', reject); + }); + check('Monitor HTML served (200)', html.status === 200); + check('Monitor HTML contains Monitor', html.body.includes('Log Monitor')); + + // 3. Create rule + const rule = await post(MONITOR, '/api/monitor/rules', { + id: 'test-rule-1', + name: 'Test: Error Detection', + enabled: true, + sources: ['sample-logs/app.log'], + patterns: ['\\bERROR\\b', '\\bFATAL\\b'], + patternLogic: 'any', + timeRange: { type: 'all' }, + notifications: [], + maxLinesInMessage: 10, + cooldownMs: 5000, + schedule: { mode: 'interval' }, + }); + check('Create rule', rule && rule.ok === true); + + // 4. Create second rule + const rule2 = await post(MONITOR, '/api/monitor/rules', { + id: 'test-rule-2', + name: 'Test: Security Audit', + enabled: true, + sources: ['sample-logs/security-audit.log'], + patterns: ['root.*login', 'DROP TABLE', 'GRANT'], + patternLogic: 'any', + timeRange: { type: 'all' }, + notifications: [{ type: 'telegram', botToken: '8116790669:AAE_llOFDFSwDhPx40vZMBa6dGv3Xxa9ZiQ', chatId: '8599028500' }], + maxLinesInMessage: 5, + cooldownMs: 5000, + schedule: { mode: 'daily', value: '07:00' }, + }); + check('Create rule with Telegram', rule2 && rule2.ok === true); + + // 5. List rules + const rules = await get(MONITOR, '/api/monitor/rules'); + check('Rules listed', Array.isArray(rules) && rules.length >= 2); + + // 6. Trigger rule 1 + const result1 = await post(MONITOR, '/api/monitor/trigger/test-rule-1', {}); + check('Trigger rule 1 (errors in app.log)', result1 && result1.totalMatches > 0); + if (result1 && result1.totalMatches) { + console.log(` → Found ${result1.totalMatches} ERROR/FATAL lines`); + } + + // 7. Trigger rule 2 (with Telegram) + const result2 = await post(MONITOR, '/api/monitor/trigger/test-rule-2', {}); + check('Trigger rule 2 (security + Telegram)', result2 && result2.totalMatches > 0); + if (result2 && result2.totalMatches) { + console.log(` → Found ${result2.totalMatches} security matches, Telegram alert sent`); + } + + // 8. Check results + const results = await get(MONITOR, '/api/monitor/results'); + check('Results stored', Array.isArray(results) && results.length >= 2); + + // 9. Update rule + const updated = await new Promise((resolve, reject) => { + const payload = JSON.stringify({ name: 'Test: Error Detection (updated)' }); + const req = http.request({ + hostname: 'localhost', port: 8082, path: '/api/monitor/rule/test-rule-1', + method: 'PUT', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }, + }, (res) => { let b = ''; res.on('data', c => b += c); res.on('end', () => resolve(JSON.parse(b))); }); + req.on('error', reject); + req.write(payload); + req.end(); + }); + check('Update rule', updated && updated.ok === true); + + // 10. Test notification endpoint + const notifTest = await post(MONITOR, '/api/monitor/test-notification', { + type: 'telegram', + botToken: '8116790669:AAE_llOFDFSwDhPx40vZMBa6dGv3Xxa9ZiQ', + chatId: '8599028500', + }); + check('Test notification (Telegram)', notifTest && notifTest.ok === true); + + // 11. Delete rule + const delResult = await del(MONITOR, '/api/monitor/rule/test-rule-1'); + check('Delete rule', delResult && delResult.ok === true); + + // 12. Monitor config + const config = await get(MONITOR, '/api/monitor/config'); + check('Monitor config endpoint', config && config.scanIntervalMs); +} + +async function main() { + console.log('\n ╔══════════════════════════════════════════╗'); + console.log(' ║ LogViewer Full Integration Test ║'); + console.log(' ╚══════════════════════════════════════════╝'); + + try { await testCollector(); } catch (e) { console.log(` ❌ Collector tests failed: ${e.message}`); failed++; } + try { await testViewer(); } catch (e) { console.log(` ❌ Viewer tests failed: ${e.message}`); failed++; } + try { await testMonitor(); } catch (e) { console.log(` ❌ Monitor tests failed: ${e.message}`); failed++; } + + console.log('\n ═══════════════════════════════════════════'); + console.log(` Results: ${passed} passed, ${failed} failed`); + console.log(' ═══════════════════════════════════════════\n'); + + process.exit(failed > 0 ? 1 : 0); +} + +main(); diff --git a/test-full-alert.js b/test-full-alert.js new file mode 100644 index 0000000..07b5179 --- /dev/null +++ b/test-full-alert.js @@ -0,0 +1,61 @@ +const http = require('http'); + +function post(path, data) { + return new Promise((resolve, reject) => { + const payload = JSON.stringify(data); + const req = http.request({ + hostname: 'localhost', port: 8082, path, + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }, + }, (res) => { + let body = ''; + res.on('data', c => body += c); + res.on('end', () => { try { resolve(JSON.parse(body)); } catch { resolve(body); } }); + }); + req.on('error', reject); + req.write(payload); + req.end(); + }); +} + +async function main() { + console.log('\n === Full Alert Test (with Telegram) ===\n'); + + // Delete old rule if exists + try { await post('/api/monitor/rule/telegram-test', {}); } catch {} + + // Create rule with Telegram notification + console.log(' Creating rule with Telegram notification...'); + const res = await post('/api/monitor/rules', { + id: 'telegram-test', + name: 'Security Alert (Telegram)', + enabled: true, + sources: ['sample-logs/security-audit.log'], + patterns: ['root.*login', 'DROP TABLE', 'GRANT.*PRIVILEGES'], + patternLogic: 'any', + timeRange: { type: 'all' }, + notifications: [{ + type: 'telegram', + botToken: '8116790669:AAE_llOFDFSwDhPx40vZMBa6dGv3Xxa9ZiQ', + chatId: '8599028500', + }], + maxLinesInMessage: 5, + cooldownMs: 10000, + matchCount: 0, + lastTriggered: null, + schedule: { mode: 'interval' }, + }); + console.log(' Rule created:', res.ok ? 'OK' : 'FAILED'); + + // Trigger it + console.log(' Triggering rule (this will send Telegram alert)...'); + const result = await post('/api/monitor/trigger/telegram-test', {}); + if (result && result.totalMatches) { + console.log(` ✅ ${result.totalMatches} matches found and Telegram alert sent!`); + console.log(' Check your Telegram for the alert message.\n'); + } else { + console.log(' No matches (unexpected).\n'); + } +} + +main().catch(e => console.error('Error:', e.message)); diff --git a/test-monitor-telegram.js b/test-monitor-telegram.js new file mode 100644 index 0000000..f0231b3 --- /dev/null +++ b/test-monitor-telegram.js @@ -0,0 +1,27 @@ +const http = require('http'); + +// Test the notification endpoint on the monitor +const payload = JSON.stringify({ + type: 'telegram', + botToken: '8116790669:AAE_llOFDFSwDhPx40vZMBa6dGv3Xxa9ZiQ', + chatId: '8599028500', +}); + +console.log('Testing Telegram notification via Monitor API...'); + +const req = http.request({ + hostname: 'localhost', port: 8082, + path: '/api/monitor/test-notification', + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }, +}, (res) => { + let body = ''; + res.on('data', c => body += c); + res.on('end', () => { + console.log(' Status:', res.statusCode); + console.log(' Response:', body); + }); +}); +req.on('error', (e) => console.error(' Error:', e.message)); +req.write(payload); +req.end(); diff --git a/test-telegram.js b/test-telegram.js new file mode 100644 index 0000000..6d4d949 --- /dev/null +++ b/test-telegram.js @@ -0,0 +1,34 @@ +const https = require('https'); + +const BOT_TOKEN = '8116790669:AAE_llOFDFSwDhPx40vZMBa6dGv3Xxa9ZiQ'; +const CHAT_ID = '8599028500'; + +const message = '🧪 Test from LogViewer Monitor\n\nIf you see this, notifications work! ✅'; + +const payload = JSON.stringify({ + chat_id: CHAT_ID, + text: message, + parse_mode: 'Markdown', +}); + +console.log('Sending to Telegram...'); +console.log(' Bot:', BOT_TOKEN.substring(0, 10) + '...'); +console.log(' Chat:', CHAT_ID); + +const req = https.request({ + hostname: 'api.telegram.org', + path: `/bot${BOT_TOKEN}/sendMessage`, + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }, +}, (res) => { + let body = ''; + res.on('data', c => body += c); + res.on('end', () => { + console.log(' Status:', res.statusCode); + console.log(' Response:', body); + }); +}); + +req.on('error', (e) => console.error(' Error:', e.message)); +req.write(payload); +req.end(); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0a6e9e4 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/tsconfig.server.json b/tsconfig.server.json new file mode 100644 index 0000000..ad1da7b --- /dev/null +++ b/tsconfig.server.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "dist-server", + "rootDir": "src/server", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "sourceMap": true + }, + "include": ["src/server/**/*"], + "exclude": ["node_modules"] +}