feat(observability): add OpenTelemetry Collector, Jaeger, and custom dashboard for monitoring
fix(infra): update NATS configuration for OpenTelemetry tracing and JetStream support refactor(dapr): modify Dapr sidecar configuration to use new resources path and config file chore(docs): update deployment documentation to include observability services docs(commit-convention): establish commit message guidelines for Asepharyana Hub docs(deploy-workflow): outline CI/CD pipeline and manual deployment steps for Asepharyana Hub feat(event-driven): implement event-driven architecture patterns with Dapr and NATS docs(hub-rules): define repository structure, submodule strategy, and infrastructure patterns
This commit is contained in:
@@ -89,7 +89,7 @@ jobs:
|
|||||||
git fetch origin main --depth=1 || true
|
git fetch origin main --depth=1 || true
|
||||||
|
|
||||||
# Detect changed files before resetting
|
# Detect changed files before resetting
|
||||||
ALL_COMPOSE_FILES="infra/compose/traefik.yml infra/compose/shared.yml infra/compose/scraper.yml infra/compose/nats.yml infra/compose/dapr.yml"
|
ALL_COMPOSE_FILES="infra/compose/traefik.yml infra/compose/shared.yml infra/compose/scraper.yml infra/compose/nats.yml infra/compose/dapr.yml infra/compose/observability.yml"
|
||||||
TRAEFIK_DYNAMIC_DIR="infra/traefik/dynamic"
|
TRAEFIK_DYNAMIC_DIR="infra/traefik/dynamic"
|
||||||
|
|
||||||
if git rev-parse HEAD >/dev/null 2>&1; then
|
if git rev-parse HEAD >/dev/null 2>&1; then
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
|
|
||||||
echo "scraper-api=$(changed '^(apps/scraper(/|$)|\.github/workflows/docker-build-push\.yml$|infra/docker/scraper\.Dockerfile$)')" >> "$GITHUB_OUTPUT"
|
echo "scraper-api=$(changed '^(apps/scraper(/|$)|\.github/workflows/docker-build-push\.yml$|infra/docker/scraper\.Dockerfile$)')" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "dashboard=$(changed '^(infra/dashboard/(/|$)|\.github/workflows/docker-build-push\.yml$|infra/docker/dashboard\.Dockerfile$)')" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Parse repository_dispatch payload
|
- name: Parse repository_dispatch payload
|
||||||
id: dispatch
|
id: dispatch
|
||||||
@@ -81,15 +82,15 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
case "$SERVICE" in
|
case "$SERVICE" in
|
||||||
scraper-api) ;;
|
scraper-api|dashboard) ;;
|
||||||
*)
|
*)
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
if ! [[ "$SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then echo "::error::Invalid sha '$SHA'. Expected 40 hex characters"; exit 1; fi
|
if ! [[ "$SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then echo "::error::Invalid sha '$SHA'. Expected 40 hex characters"; fi
|
||||||
|
|
||||||
SERVICES=(scraper-api)
|
SERVICES=(scraper-api dashboard)
|
||||||
for svc in "${SERVICES[@]}"; do
|
for svc in "${SERVICES[@]}"; do
|
||||||
if [ "$SERVICE" = "$svc" ]; then
|
if [ "$SERVICE" = "$svc" ]; then
|
||||||
echo "${svc}=true" >> "$GITHUB_OUTPUT"
|
echo "${svc}=true" >> "$GITHUB_OUTPUT"
|
||||||
@@ -107,6 +108,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
|
|
||||||
if [ "${{ steps.filter.outputs['scraper-api'] == 'true' || steps.dispatch.outputs['scraper-api'] == 'true' || github.event_name == 'workflow_dispatch' }}" == "true" ]; then add_service "scraper-api" "docker-scraper" "apps/scraper"; fi
|
if [ "${{ steps.filter.outputs['scraper-api'] == 'true' || steps.dispatch.outputs['scraper-api'] == 'true' || github.event_name == 'workflow_dispatch' }}" == "true" ]; then add_service "scraper-api" "docker-scraper" "apps/scraper"; fi
|
||||||
|
if [ "${{ steps.filter.outputs['dashboard'] == 'true' || steps.dispatch.outputs['dashboard'] == 'true' || github.event_name == 'workflow_dispatch' }}" == "true" ]; then add_service "dashboard" "docker-dashboard" "infra/dashboard"; fi
|
||||||
|
|
||||||
JSON_ARRAY="[$(IFS=,; echo "${SERVICES[*]}")]"
|
JSON_ARRAY="[$(IFS=,; echo "${SERVICES[*]}")]"
|
||||||
echo "matrix=$JSON_ARRAY" >> $GITHUB_OUTPUT
|
echo "matrix=$JSON_ARRAY" >> $GITHUB_OUTPUT
|
||||||
@@ -207,6 +209,7 @@ jobs:
|
|||||||
echo "cache-registry=${REGISTRY}/${IMAGE_NAME_PREFIX}/${SVC_NAME}:buildcache" >> $GITHUB_OUTPUT
|
echo "cache-registry=${REGISTRY}/${IMAGE_NAME_PREFIX}/${SVC_NAME}:buildcache" >> $GITHUB_OUTPUT
|
||||||
case "$SVC_NAME" in
|
case "$SVC_NAME" in
|
||||||
"scraper-api") echo "dockerfile=infra/docker/scraper.Dockerfile" >> $GITHUB_OUTPUT ;;
|
"scraper-api") echo "dockerfile=infra/docker/scraper.Dockerfile" >> $GITHUB_OUTPUT ;;
|
||||||
|
"dashboard") echo "dockerfile=infra/docker/dashboard.Dockerfile" >> $GITHUB_OUTPUT ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
- name: Build and Push Docker image
|
- name: Build and Push Docker image
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
name: commit-convention
|
||||||
|
description: Commit message convention — type(scope): description for Asepharyana Hub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Commit Convention — Asepharyana Hub
|
||||||
|
|
||||||
|
## Format
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <description>
|
||||||
|
|
||||||
|
[optional body]
|
||||||
|
|
||||||
|
[optional footer]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
| Type | Usage |
|
||||||
|
| ---------- | ------------------------------------ |
|
||||||
|
| `feat` | Fitur baru |
|
||||||
|
| `fix` | Bug fix |
|
||||||
|
| `chore` | Maintenance, config, tooling |
|
||||||
|
| `docs` | Dokumentasi |
|
||||||
|
| `refactor` | Perubahan kode tanpa fungsional baru |
|
||||||
|
| `test` | Nambah/update test |
|
||||||
|
| `ci` | CI/CD workflows |
|
||||||
|
| `perf` | Optimasi performa |
|
||||||
|
| `style` | Formatting (tanda kutip, dll) |
|
||||||
|
|
||||||
|
## Scopes
|
||||||
|
|
||||||
|
| Scope | Area |
|
||||||
|
| ------------- | --------------------------------- |
|
||||||
|
| `scraper` | apps/scraper submodule |
|
||||||
|
| `infra` | infra/ (compose, traefik, docker) |
|
||||||
|
| `ci` | .github/workflows/ |
|
||||||
|
| `dapr` | Dapr config & sidecar |
|
||||||
|
| `nats` | NATS message bus |
|
||||||
|
| `docs` | Dokumentasi |
|
||||||
|
| `deps` | Dependencies |
|
||||||
|
| `scripts` | Utility scripts |
|
||||||
|
| `root` | Root config files |
|
||||||
|
|
||||||
|
## Contoh
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(scraper): add anime detail caching via Dapr pubsub
|
||||||
|
fix(infra): correct NATS CLI flags for JetStream
|
||||||
|
chore(deps): update biome to v2.5.3
|
||||||
|
docs(infra): add deployment order for Dapr services
|
||||||
|
ci(deploy): add nats.yml to ALL_COMPOSE_FILES
|
||||||
|
refactor(scraper): migrate EventBus from tokio broadcast to Dapr pubsub
|
||||||
|
```
|
||||||
|
|
||||||
|
## Aturan
|
||||||
|
|
||||||
|
1. **Wajib** menyertakan scope dalam tanda kurung
|
||||||
|
2. **Wajib** `Co-Authored-By` untuk commit yang digenerate AI
|
||||||
|
3. **Gunakan imperative mood**: "add" bukan "added" / "adds"
|
||||||
|
4. **Jangan capitalize** type: `feat:` bukan `Feat:`
|
||||||
|
5. **No period** di akhir subject baris
|
||||||
|
6. Body explain **why** dan **what**, bukan **how**
|
||||||
|
7. Refer issue dengan `Closes #123` atau `Fixes #123` di footer
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
---
|
||||||
|
name: deploy-workflow
|
||||||
|
description: CI/CD pipeline, Docker build patterns, manual deploy steps, and troubleshooting for Asepharyana Hub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Deploy & Workflow — Asepharyana Hub
|
||||||
|
|
||||||
|
## CI/CD Pipeline
|
||||||
|
|
||||||
|
### Build Pipeline (`docker-build-push.yml`)
|
||||||
|
Trigger: push ke `main` yang touch `apps/**`, `infra/**`, `infra/docker/**`
|
||||||
|
|
||||||
|
1. **changes** — detect service mana yg berubah via git diff
|
||||||
|
2. **wait-submodule-ref** — (repository_dispatch only) tunggu SHA commit fetchable
|
||||||
|
3. **build** — matrix build per service, push ke GHCR (`sha-<short>` + `latest`)
|
||||||
|
4. **update-manifest** — update image tag di compose file, commit + push
|
||||||
|
|
||||||
|
### Deploy Pipeline (`deploy-docker.yml`)
|
||||||
|
Trigger: build selesai, atau push ke `main` touch `infra/**`
|
||||||
|
|
||||||
|
1. SSH ke `orangevps` (via `secrets.VPS_HOST`)
|
||||||
|
2. Sync repo (`git fetch --depth=1 + reset`)
|
||||||
|
3. Login ke GHCR
|
||||||
|
4. Deteksi compose file yg berubah
|
||||||
|
5. Pull images + restart container selektif
|
||||||
|
|
||||||
|
### Secrets Required
|
||||||
|
| Secret | Untuk |
|
||||||
|
|--------|-------|
|
||||||
|
| `SSH_PRIVATE_KEY` | SSH ke VPS |
|
||||||
|
| `VPS_HOST` | IP/host VPS (tailscale IP) |
|
||||||
|
| `VPS_USER` | SSH user, biasanya `root` |
|
||||||
|
| `VPS_TARGET_DIR` | Lokasi repo di VPS |
|
||||||
|
| `ENV_FILE_PRODUCTION` | .env content untuk production |
|
||||||
|
|
||||||
|
### Selective Deployment
|
||||||
|
- Hanya compose file yg berubah yang di-redeploy
|
||||||
|
- Selective: `UP_FLAGS="-d"` (tanpa `--remove-orphans`)
|
||||||
|
- Full deploy: `UP_FLAGS="-d --remove-orphans"`
|
||||||
|
|
||||||
|
## Docker Patterns
|
||||||
|
|
||||||
|
### Build dengan cargo-chef (Rust)
|
||||||
|
```dockerfile
|
||||||
|
FROM lukemathwalker/cargo-chef:latest-rust-1.89.0 AS chef
|
||||||
|
WORKDIR /app
|
||||||
|
FROM chef AS planner
|
||||||
|
COPY apps/scraper .
|
||||||
|
RUN cargo chef prepare --recipe-path recipe.json
|
||||||
|
FROM chef AS builder
|
||||||
|
COPY --from=planner /app/recipe.json recipe.json
|
||||||
|
RUN cargo chef cook --release --recipe-path recipe.json
|
||||||
|
COPY apps/scraper .
|
||||||
|
RUN cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
### Runtime minimal untuk Rust binary
|
||||||
|
```dockerfile
|
||||||
|
FROM debian:bookworm-slim AS runtime
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates curl libssl3 && rm -rf /var/lib/apt/lists/*
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual Deploy Steps
|
||||||
|
```bash
|
||||||
|
# 1. Login GHCR
|
||||||
|
echo $GITHUB_TOKEN | docker login ghcr.io -u asepharyana --password-stdin
|
||||||
|
|
||||||
|
# 2. Full stack
|
||||||
|
docker compose -f infra/compose/traefik.yml \
|
||||||
|
-f infra/compose/shared.yml \
|
||||||
|
-f infra/compose/nats.yml \
|
||||||
|
-f infra/compose/dapr.yml \
|
||||||
|
-f infra/compose/scraper.yml \
|
||||||
|
--env-file .env up -d --remove-orphans
|
||||||
|
|
||||||
|
# 3. Selective (hanya satu service)
|
||||||
|
docker compose -f infra/compose/scraper.yml --env-file .env up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Container reach Tailscale
|
||||||
|
Pastikan route ke Tailscale di main table:
|
||||||
|
```bash
|
||||||
|
ip route add 100.64.0.0/10 dev tailscale0 table main
|
||||||
|
systemctl restart tailscale-routes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Healthcheck gagal di scratch images
|
||||||
|
NATS dan Dapr placement pake scratch — tidak bisa healthcheck. Cukup `service_started` di depends_on.
|
||||||
|
|
||||||
|
### Dapr sidecar crash
|
||||||
|
```bash
|
||||||
|
docker logs scraper-api-dapr | grep -iE "fatal|error"
|
||||||
|
```
|
||||||
|
Penyebab umum: komponen config salah, NATS/Dapr placement belum siap.
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
---
|
||||||
|
name: event-driven
|
||||||
|
description: Event-driven architecture patterns with Dapr + NATS for Asepharyana Hub services
|
||||||
|
---
|
||||||
|
|
||||||
|
# Event-Driven Architecture — Asepharyana Hub
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
- **Message Backbone**: NATS + JetStream (untuk streaming & job queue)
|
||||||
|
- **Pub/Sub Runtime**: Dapr sidecar per service (pubsub via Redis built-in)
|
||||||
|
- **State Store**: Dapr → Redis
|
||||||
|
|
||||||
|
## Event Topics Convention
|
||||||
|
|
||||||
|
```
|
||||||
|
hub.<domain>.<action>
|
||||||
|
|
||||||
|
Contoh:
|
||||||
|
hub.image.cached → Image selesai di-cache ke CDN
|
||||||
|
hub.image.repaired → Image diperbaiki (CNAME change)
|
||||||
|
hub.scrape.anime.done → Scrape anime selesai
|
||||||
|
hub.system.alert → Error/alert dari service
|
||||||
|
```
|
||||||
|
|
||||||
|
## CloudEvents Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"specversion": "1.0",
|
||||||
|
"type": "hub.image.cached",
|
||||||
|
"source": "scraper-api",
|
||||||
|
"subject": "anime-poster",
|
||||||
|
"id": "uuid-v4",
|
||||||
|
"time": "2026-07-21T10:00:00Z",
|
||||||
|
"datacontenttype": "application/json",
|
||||||
|
"data": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Publish Event (Rust via HTTP API)
|
||||||
|
|
||||||
|
Gunakan `reqwest` langsung ke Dapr sidecar (SDK Rust masih experimental):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let event = serde_json::json!({
|
||||||
|
"specversion": "1.0",
|
||||||
|
"type": "hub.image.cached",
|
||||||
|
"source": "scraper-api",
|
||||||
|
"id": Uuid::new_v4().to_string(),
|
||||||
|
"time": chrono::Utc::now().to_rfc3339(),
|
||||||
|
"datacontenttype": "application/json",
|
||||||
|
"data": { "original_url": url, "cdn_url": cdn_url }
|
||||||
|
});
|
||||||
|
|
||||||
|
reqwest::Client::new()
|
||||||
|
.post("http://localhost:3500/v1.0/publish/pubsub/hub.image.cached")
|
||||||
|
.json(&event)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Service Invocation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3500/v1.0/invoke/<app-id>/method/<path>
|
||||||
|
```
|
||||||
|
|
||||||
|
## State Store
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set
|
||||||
|
curl -X POST http://localhost:3500/v1.0/state/statestore \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '[{"key": "mykey", "value": "myvalue"}]'
|
||||||
|
|
||||||
|
# Get
|
||||||
|
curl http://localhost:3500/v1.0/state/statestore/mykey
|
||||||
|
|
||||||
|
# Delete
|
||||||
|
curl -X DELETE http://localhost:3500/v1.0/state/statestore/mykey
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scraper Event Integration
|
||||||
|
|
||||||
|
File yang perlu dimodifikasi untuk event-driven:
|
||||||
|
|
||||||
|
| File | Perubahan |
|
||||||
|
|------|-----------|
|
||||||
|
| `src/events/bus.rs` | Ganti backend dari tokio broadcast ke Dapr pub/sub |
|
||||||
|
| `src/bootstrap/mod.rs` | Init DaprClient, inject ke AppState |
|
||||||
|
| `src/presentation/state.rs` | Tambah `dapr_client` field |
|
||||||
|
| `src/proxy/use_cases.rs` | Publish `ImageRepaired` & `ImageCached` events |
|
||||||
|
| `src/infrastructure/services/images/cache.rs` | Emit event tiap cache selesai |
|
||||||
|
| `Cargo.toml` | Tambah `reqwest`, `uuid`, `chrono` (jika belum ada) |
|
||||||
|
|
||||||
|
## Event Handlers (Subscribe)
|
||||||
|
|
||||||
|
Buat `src/subscribers/` untuk handler:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// src/subscribers/image_handler.rs
|
||||||
|
pub async fn handle_image_cached(event: CloudEvent) -> Result<()> {
|
||||||
|
// Log, notifikasi, update status
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Daftarkan subscribers di `bootstrap/mod.rs` dengan spawn task:
|
||||||
|
```rust
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut stream = dapr_client.subscribe("pubsub", "hub.image.cached");
|
||||||
|
while let Some(event) = stream.next().await {
|
||||||
|
handle_image_cached(event).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Event-Driven Code
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_publish_event() {
|
||||||
|
let client = MockDaprClient::new();
|
||||||
|
client.expect_publish()
|
||||||
|
.with(...)
|
||||||
|
.returning(|_| Ok(()));
|
||||||
|
// ... test
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
---
|
||||||
|
name: hub-rules
|
||||||
|
description: Repository structure, submodule strategy, infrastructure patterns, and architecture of Asepharyana Hub
|
||||||
|
---
|
||||||
|
|
||||||
|
# Asepharyana Hub — Repository Rules
|
||||||
|
|
||||||
|
## Struktur Repository
|
||||||
|
|
||||||
|
```
|
||||||
|
asepharyana-hub/
|
||||||
|
├── apps/ # Git submodules — source code aplikasi
|
||||||
|
├── docs/ # Dokumentasi, ADR, deployment guide
|
||||||
|
├── infra/ # Infrastructure as code
|
||||||
|
│ ├── compose/ # Satu compose file per service
|
||||||
|
│ ├── dapr/ # Dapr component configs
|
||||||
|
│ ├── docker/ # Dockerfiles per service
|
||||||
|
│ └── traefik/ # Static & dynamic Traefik config
|
||||||
|
├── scripts/ # Utility scripts (cleanup, update-deps)
|
||||||
|
└── .github/workflows/ # CI/CD pipelines
|
||||||
|
```
|
||||||
|
|
||||||
|
### Aturan Submodule
|
||||||
|
- Setiap aplikasi di `apps/` adalah **submodule** ke repo terpisah.
|
||||||
|
- Perubahan kode aplikasi dilakukan di **repo masing-masing**, bukan di sini.
|
||||||
|
- Submodule pointer diupdate oleh CI/CD (bukan manual).
|
||||||
|
|
||||||
|
## Infrastructure Patterns
|
||||||
|
|
||||||
|
### Networking
|
||||||
|
- Semua service join **`app-shared-net`** (external Docker bridge)
|
||||||
|
- Service discovery via Docker DNS (container alias)
|
||||||
|
- Traefik sebagai ingress untuk HTTP/S eksternal
|
||||||
|
- Tailscale untuk cross-VPS (PostgreSQL, Redis)
|
||||||
|
|
||||||
|
### Compose File Pattern
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
<service>:
|
||||||
|
container_name: <service>
|
||||||
|
image: ghcr.io/asepharyana/asepharyana-hub/<service>:sha-<sha>
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
app-shared-net:
|
||||||
|
aliases:
|
||||||
|
- <service>
|
||||||
|
env_file:
|
||||||
|
- ../../.env
|
||||||
|
|
||||||
|
networks:
|
||||||
|
app-shared-net:
|
||||||
|
name: app-shared-net
|
||||||
|
external: true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dapr Sidecar Pattern
|
||||||
|
```yaml
|
||||||
|
<service>-dapr:
|
||||||
|
container_name: <service>-dapr
|
||||||
|
image: daprio/daprd:latest
|
||||||
|
restart: always
|
||||||
|
depends_on:
|
||||||
|
nats:
|
||||||
|
condition: service_started
|
||||||
|
dapr-placement:
|
||||||
|
condition: service_started
|
||||||
|
networks:
|
||||||
|
- app-shared-net
|
||||||
|
command:
|
||||||
|
- './daprd'
|
||||||
|
- '--app-id=<service>'
|
||||||
|
- '--app-port=<port>'
|
||||||
|
- '--dapr-http-port=3500'
|
||||||
|
- '--dapr-grpc-port=50001'
|
||||||
|
- '--placement-host-address=dapr-placement:50005'
|
||||||
|
- '--resources-path=/components'
|
||||||
|
volumes:
|
||||||
|
- ../../infra/dapr/components:/components
|
||||||
|
```
|
||||||
|
|
||||||
|
### Traefik Routing
|
||||||
|
- Router + service definition di `infra/traefik/dynamic/apps.yaml`
|
||||||
|
- Subdomain pattern: `<service>.asepharyana.my.id` + `<service>.asepharya.web.id`
|
||||||
|
- TLS cert dari volume mount (bukan auto-acme)
|
||||||
|
|
||||||
|
### Image Tagging
|
||||||
|
- `sha-<short-sha>` — immutable, untuk rollback
|
||||||
|
- `latest` — mutable, untuk convenience
|
||||||
|
- Registry: `ghcr.io/asepharyana/asepharyana-hub/<service>`
|
||||||
|
|
||||||
|
### CI/CD
|
||||||
|
- `docker-build-push.yml` — build per service, push ke GHCR, update compose manifest
|
||||||
|
- `deploy-docker.yml` — SSH ke orangevps, pull images, restart
|
||||||
|
- Selective deploy: hanya compose file yg berubah
|
||||||
|
|
||||||
|
## Deployment Order
|
||||||
|
1. `shared.yml` (Redis)
|
||||||
|
2. `nats.yml` (NATS message bus)
|
||||||
|
3. `dapr.yml` (Dapr placement)
|
||||||
|
4. `traefik.yml` (Reverse proxy)
|
||||||
|
5. Service compose files (apps + Dapr sidecar)
|
||||||
+7
-7
@@ -644,14 +644,13 @@ Proyek compose bernama `compose`, terdiri dari 5 file yang selalu di-include ber
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
/root/asepharyana-hub/infra/compose/
|
/root/asepharyana-hub/infra/compose/
|
||||||
├── traefik.yml # Reverse proxy
|
├── traefik.yml # Reverse proxy
|
||||||
├── shared.yml # Redis
|
├── shared.yml # Redis
|
||||||
├── nats.yml # NATS
|
├── nats.yml # NATS
|
||||||
├── dapr.yml # Dapr placement
|
├── dapr.yml # Dapr placement
|
||||||
└── scraper.yml # Scraper API
|
├── scraper.yml # Scraper API
|
||||||
```
|
└── observability.yml # OTel Collector, Jaeger, Dashboard
|
||||||
|
|
||||||
Perintah restart setelah update `.env` di VPS:
|
|
||||||
```bash
|
```bash
|
||||||
cd /root/asepharyana-hub
|
cd /root/asepharyana-hub
|
||||||
docker compose \
|
docker compose \
|
||||||
@@ -662,6 +661,7 @@ docker compose \
|
|||||||
-f infra/compose/scraper.yml \
|
-f infra/compose/scraper.yml \
|
||||||
-f infra/compose/nats.yml \
|
-f infra/compose/nats.yml \
|
||||||
-f infra/compose/dapr.yml \
|
-f infra/compose/dapr.yml \
|
||||||
|
-f infra/compose/observability.yml \
|
||||||
up -d --remove-orphans
|
up -d --remove-orphans
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -70,11 +70,12 @@ The VPS runs a single Docker Compose project named `compose` composed of multipl
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
/root/asepharyana-hub/infra/compose/
|
/root/asepharyana-hub/infra/compose/
|
||||||
├── traefik.yml # Reverse proxy (TLS termination, routing)
|
├── traefik.yml # Reverse proxy (TLS termination, routing)
|
||||||
├── shared.yml # Redis
|
├── shared.yml # Redis
|
||||||
├── nats.yml # NATS message broker + JetStream
|
├── nats.yml # NATS message broker + JetStream
|
||||||
├── dapr.yml # Dapr placement service
|
├── dapr.yml # Dapr placement service
|
||||||
└── scraper.yml # Scraper API + Dapr sidecar
|
├── scraper.yml # Scraper API + Dapr sidecar
|
||||||
|
└── observability.yml # OTel Collector + Jaeger + Dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
All files are always included together for dependency resolution:
|
All files are always included together for dependency resolution:
|
||||||
@@ -87,6 +88,7 @@ docker compose \
|
|||||||
-f infra/compose/scraper.yml \
|
-f infra/compose/scraper.yml \
|
||||||
-f infra/compose/nats.yml \
|
-f infra/compose/nats.yml \
|
||||||
-f infra/compose/dapr.yml \
|
-f infra/compose/dapr.yml \
|
||||||
|
-f infra/compose/observability.yml \
|
||||||
up -d
|
up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,10 @@ services:
|
|||||||
- '4222:4222' # client connections
|
- '4222:4222' # client connections
|
||||||
- '8222:8222' # HTTP monitor / health
|
- '8222:8222' # HTTP monitor / health
|
||||||
command:
|
command:
|
||||||
- '-js'
|
- '--config=/etc/nats/nats.conf'
|
||||||
- '-sd'
|
|
||||||
- '/data'
|
|
||||||
volumes:
|
volumes:
|
||||||
- nats_data:/data
|
- nats_data:/data
|
||||||
|
- ../../infra/nats/nats.conf:/etc/nats/nats.conf:ro
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
nats_data:
|
nats_data:
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
services:
|
||||||
|
# ── OpenTelemetry Collector ──
|
||||||
|
otel-collector:
|
||||||
|
container_name: otel-collector
|
||||||
|
image: otel/opentelemetry-collector-contrib:latest
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
app-shared-net:
|
||||||
|
aliases:
|
||||||
|
- otel-collector
|
||||||
|
ports:
|
||||||
|
- '4317:4317' # OTLP gRPC
|
||||||
|
- '4318:4318' # OTLP HTTP
|
||||||
|
- '8889:8889' # Prometheus metrics
|
||||||
|
command:
|
||||||
|
- '--config=/etc/otel/config.yml'
|
||||||
|
volumes:
|
||||||
|
- ../../infra/otel/otel-collector-config.yml:/etc/otel/config.yml:ro
|
||||||
|
depends_on:
|
||||||
|
jaeger:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
# ── Jaeger (Tracing Backend + Built-in UI) ──
|
||||||
|
jaeger:
|
||||||
|
container_name: jaeger
|
||||||
|
image: jaegertracing/all-in-one:latest
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
app-shared-net:
|
||||||
|
aliases:
|
||||||
|
- jaeger
|
||||||
|
ports:
|
||||||
|
- '16686:16686' # Jaeger UI + API
|
||||||
|
environment:
|
||||||
|
- COLLECTOR_OTLP_ENABLED=true
|
||||||
|
- COLLECTOR_ZIPKIN_HOST_PORT=:9411
|
||||||
|
- METRICS_STORAGE_TYPE=prometheus
|
||||||
|
- PROMETHEUS_SERVER_URL=http://otel-collector:8889
|
||||||
|
- LOG_LEVEL=info
|
||||||
|
|
||||||
|
# ── Custom Dashboard (nginx:alpine) ──
|
||||||
|
dashboard:
|
||||||
|
container_name: dashboard
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: infra/docker/dashboard.Dockerfile
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
app-shared-net:
|
||||||
|
aliases:
|
||||||
|
- dashboard
|
||||||
|
ports:
|
||||||
|
- '8080:8080'
|
||||||
|
|
||||||
|
networks:
|
||||||
|
app-shared-net:
|
||||||
|
name: app-shared-net
|
||||||
|
external: true
|
||||||
@@ -35,6 +35,8 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
dapr-placement:
|
dapr-placement:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
otel-collector:
|
||||||
|
condition: service_started
|
||||||
networks:
|
networks:
|
||||||
- app-shared-net
|
- app-shared-net
|
||||||
command:
|
command:
|
||||||
@@ -44,9 +46,10 @@ services:
|
|||||||
- '--dapr-http-port=3500'
|
- '--dapr-http-port=3500'
|
||||||
- '--dapr-grpc-port=50001'
|
- '--dapr-grpc-port=50001'
|
||||||
- '--placement-host-address=dapr-placement:50005'
|
- '--placement-host-address=dapr-placement:50005'
|
||||||
- '--resources-path=/components'
|
- '--config=/dapr/config.yaml'
|
||||||
|
- '--resources-path=/dapr/components'
|
||||||
volumes:
|
volumes:
|
||||||
- ../../infra/dapr/components:/components
|
- ../../infra/dapr:/dapr:ro
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
app-shared-net:
|
app-shared-net:
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ services:
|
|||||||
- '--experimental.plugins.blockpath.moduleName=github.com/traefik/plugin-blockpath'
|
- '--experimental.plugins.blockpath.moduleName=github.com/traefik/plugin-blockpath'
|
||||||
- '--experimental.plugins.blockpath.version=v0.2.1'
|
- '--experimental.plugins.blockpath.version=v0.2.1'
|
||||||
- '--ping=true'
|
- '--ping=true'
|
||||||
|
- '--tracing.openTelemetry=true'
|
||||||
|
- '--tracing.openTelemetry.address=otel-collector:4318'
|
||||||
|
- '--tracing.openTelemetry.insecure=true'
|
||||||
|
- '--tracing.openTelemetry.grpc=false'
|
||||||
|
- '--tracing.serviceName=traefik'
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD', 'wget', '--spider', 'http://localhost:8080/ping']
|
test: ['CMD', 'wget', '--spider', 'http://localhost:8080/ping']
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ metadata:
|
|||||||
spec:
|
spec:
|
||||||
tracing:
|
tracing:
|
||||||
samplingRate: "1"
|
samplingRate: "1"
|
||||||
zipkin:
|
stdout: false
|
||||||
endpointAddress: ""
|
otel:
|
||||||
|
endpointAddress: "otel-collector:4317"
|
||||||
|
isSecure: false
|
||||||
|
protocol: grpc
|
||||||
metrics:
|
metrics:
|
||||||
enabled: true
|
enabled: true
|
||||||
mtls:
|
mtls:
|
||||||
|
|||||||
@@ -0,0 +1,383 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Hub Dashboard</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0d1117;
|
||||||
|
--bg-card: #161b22;
|
||||||
|
--bg-hover: #1c2333;
|
||||||
|
--border: #30363d;
|
||||||
|
--text: #e6edf3;
|
||||||
|
--text-dim: #8b949e;
|
||||||
|
--accent: #58a6ff;
|
||||||
|
--green: #3fb950;
|
||||||
|
--yellow: #d29922;
|
||||||
|
--red: #f85149;
|
||||||
|
--orange: #d4760b;
|
||||||
|
}
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 20px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
margin-bottom: 24px; padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.header h1 { font-size: 24px; font-weight: 600; }
|
||||||
|
.header .subtitle { color: var(--text-dim); font-size: 13px; }
|
||||||
|
.header .status-bar { display: flex; gap: 16px; align-items: center; }
|
||||||
|
.header .status-bar .dot {
|
||||||
|
width: 10px; height: 10px; border-radius: 50%; display: inline-block;
|
||||||
|
}
|
||||||
|
.header .last-updated { color: var(--text-dim); font-size: 12px; }
|
||||||
|
.grid {
|
||||||
|
display: grid; gap: 16px;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px; padding: 16px;
|
||||||
|
}
|
||||||
|
.card h2 {
|
||||||
|
font-size: 14px; font-weight: 600; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em; color: var(--text-dim); margin-bottom: 12px;
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
}
|
||||||
|
.card h2 .count {
|
||||||
|
font-size: 12px; background: var(--bg-hover); padding: 2px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.service-list, .trace-list { list-style: none; }
|
||||||
|
.service-item {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 8px 0; border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.service-item:last-child { border-bottom: none; }
|
||||||
|
.service-item .name { font-family: 'SF Mono', 'Fira Code', monospace; }
|
||||||
|
.service-item .badge {
|
||||||
|
font-size: 11px; padding: 2px 8px; border-radius: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.badge-up { background: rgba(63,185,80,0.15); color: var(--green); }
|
||||||
|
.badge-down { background: rgba(248,81,73,0.15); color: var(--red); }
|
||||||
|
.badge-degraded { background: rgba(210,153,34,0.15); color: var(--yellow); }
|
||||||
|
|
||||||
|
.trace-item {
|
||||||
|
padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px;
|
||||||
|
}
|
||||||
|
.trace-item:last-child { border-bottom: none; }
|
||||||
|
.trace-item .trace-service { font-weight: 500; }
|
||||||
|
.trace-item .trace-op {
|
||||||
|
color: var(--text-dim); font-size: 12px;
|
||||||
|
font-family: 'SF Mono', 'Fira Code', monospace; margin: 2px 0;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.trace-item .trace-meta {
|
||||||
|
display: flex; gap: 12px; font-size: 11px; color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.trace-item .trace-duration {
|
||||||
|
font-family: 'SF Mono', monospace; font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||||
|
.stat-box {
|
||||||
|
background: var(--bg-hover); border-radius: 6px; padding: 12px; text-align: center;
|
||||||
|
}
|
||||||
|
.stat-value {
|
||||||
|
font-size: 28px; font-weight: 700; font-family: 'SF Mono', monospace;
|
||||||
|
}
|
||||||
|
.stat-label { font-size: 11px; color: var(--text-dim); margin-top: 4px; }
|
||||||
|
|
||||||
|
.links { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.links a {
|
||||||
|
color: var(--accent); text-decoration: none; font-size: 13px;
|
||||||
|
padding: 6px 12px; border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.links a:hover { background: var(--bg-hover); border-color: var(--accent); }
|
||||||
|
|
||||||
|
.loading { text-align: center; padding: 20px; color: var(--text-dim); }
|
||||||
|
|
||||||
|
.inline-error {
|
||||||
|
background: rgba(248,81,73,0.1); border: 1px solid rgba(248,81,73,0.3);
|
||||||
|
border-radius: 6px; padding: 8px 12px; font-size: 12px;
|
||||||
|
color: var(--red); margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.grid { grid-template-columns: 1fr; }
|
||||||
|
.header { flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="header">
|
||||||
|
<div>
|
||||||
|
<h1>asepharyana-hub</h1>
|
||||||
|
<div class="subtitle">System Dashboard</div>
|
||||||
|
</div>
|
||||||
|
<div class="status-bar">
|
||||||
|
<span id="health-indicator" title="System Health"><span class="dot" style="background:var(--yellow)"></span> Loading...</span>
|
||||||
|
<span class="last-updated" id="last-updated">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<!-- Services -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Services <span class="count" id="service-count">0</span></h2>
|
||||||
|
<div id="service-list"><div class="loading">Loading...</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Overview</h2>
|
||||||
|
<div class="stat-grid">
|
||||||
|
<div class="stat-box">
|
||||||
|
<div class="stat-value" id="stat-services">-</div>
|
||||||
|
<div class="stat-label">Services</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-box">
|
||||||
|
<div class="stat-value" id="stat-traces">-</div>
|
||||||
|
<div class="stat-label">Traces (5m)</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-box">
|
||||||
|
<div class="stat-value" id="stat-errors">-</div>
|
||||||
|
<div class="stat-label">Errors (5m)</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-box">
|
||||||
|
<div class="stat-value" id="stat-p99">-</div>
|
||||||
|
<div class="stat-label">P99 Latency</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Links -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Quick Links</h2>
|
||||||
|
<div class="links">
|
||||||
|
<a href="/jaeger" target="_blank">Jaeger UI</a>
|
||||||
|
<a href="/api/metrics" target="_blank">OTel Metrics</a>
|
||||||
|
<a href="https://traefik.asepharyana.my.id" target="_blank">Traefik</a>
|
||||||
|
<a href="https://scraper.asepharyana.my.id" target="_blank">Scraper API</a>
|
||||||
|
<a href="https://github.com/asepharyana/asepharyana-hub" target="_blank">GitHub</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Traces -->
|
||||||
|
<div class="card" style="grid-column: 1 / -1;">
|
||||||
|
<h2>Recent Traces <span class="count" id="trace-count">0</span></h2>
|
||||||
|
<div id="trace-list"><div class="loading">Loading...</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const JAEGER_API = '/api/jaeger';
|
||||||
|
const TRACE_LIMIT = 20;
|
||||||
|
|
||||||
|
async function fetchJSON(url) {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeTime(ms) {
|
||||||
|
const sec = Math.floor(ms / 1000);
|
||||||
|
if (sec < 60) return `${sec}s ago`;
|
||||||
|
const min = Math.floor(sec / 60);
|
||||||
|
if (min < 60) return `${min}m ago`;
|
||||||
|
const hr = Math.floor(min / 60);
|
||||||
|
return `${hr}h ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(us) {
|
||||||
|
if (us < 1000) return `${us}µs`;
|
||||||
|
if (us < 1_000_000) return `${(us / 1000).toFixed(1)}ms`;
|
||||||
|
return `${(us / 1_000_000).toFixed(2)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowMicro() {
|
||||||
|
return Date.now() * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function microToISO(us) {
|
||||||
|
return new Date(us / 1000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTraces() {
|
||||||
|
const el = document.getElementById('trace-list');
|
||||||
|
const countEl = document.getElementById('trace-count');
|
||||||
|
try {
|
||||||
|
const services = await fetchJSON(`${JAEGER_API}/api/services`);
|
||||||
|
let allTraces = [];
|
||||||
|
const lookback = 5 * 60 * 1_000_000; // 5 minutes in microseconds
|
||||||
|
const now = nowMicro();
|
||||||
|
const start = now - lookback;
|
||||||
|
|
||||||
|
for (const svc of services.data || []) {
|
||||||
|
try {
|
||||||
|
const data = await fetchJSON(
|
||||||
|
`${JAEGER_API}/api/traces?service=${encodeURIComponent(svc)}&start=${start}&end=${now}&limit=5&lookback=5m`
|
||||||
|
);
|
||||||
|
if (data.data) allTraces = allTraces.concat(data.data);
|
||||||
|
} catch(e) { /* skip service if error */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by start time descending
|
||||||
|
allTraces.sort((a, b) => (b.startTime || 0) - (a.startTime || 0));
|
||||||
|
allTraces = allTraces.slice(0, TRACE_LIMIT);
|
||||||
|
|
||||||
|
countEl.textContent = allTraces.length;
|
||||||
|
|
||||||
|
if (allTraces.length === 0) {
|
||||||
|
el.innerHTML = '<div style="color:var(--text-dim);font-size:13px;text-align:center;padding:12px;">No traces in the last 5 minutes</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.innerHTML = allTraces.map(t => {
|
||||||
|
const duration = t.duration || 0;
|
||||||
|
const span = (t.spans && t.spans[0]) || {};
|
||||||
|
const proc = span.process || {};
|
||||||
|
const svcName = proc.serviceName || (t.processes && Object.values(t.processes)[0]?.serviceName) || 'unknown';
|
||||||
|
const opName = span.operationName || 'unknown';
|
||||||
|
const startTime = t.startTime ? microToISO(t.startTime) : '-';
|
||||||
|
const spanCount = (t.spans && t.spans.length) || 0;
|
||||||
|
const errSpan = t.spans && t.spans.find(s => (s.tags || []).some(tg => tg.key === 'error' && tg.value === true));
|
||||||
|
const errClass = errSpan ? 'color:var(--red)' : '';
|
||||||
|
|
||||||
|
return `<div class="trace-item">
|
||||||
|
<div class="trace-service" style="${errClass}">${escHtml(svcName)}</div>
|
||||||
|
<div class="trace-op">${escHtml(opName)}</div>
|
||||||
|
<div class="trace-meta">
|
||||||
|
<span class="trace-duration">${formatDuration(duration)}</span>
|
||||||
|
<span>${spanCount} spans</span>
|
||||||
|
<span>${relativeTime(Date.now() - new Date(startTime).getTime())}</span>
|
||||||
|
${errSpan ? '<span style="color:var(--red)">✗ error</span>' : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
} catch(e) {
|
||||||
|
el.innerHTML = `<div class="inline-error">Failed to load traces: ${escHtml(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = s;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadServices() {
|
||||||
|
const listEl = document.getElementById('service-list');
|
||||||
|
const countEl = document.getElementById('service-count');
|
||||||
|
const statSvc = document.getElementById('stat-services');
|
||||||
|
|
||||||
|
const services = [
|
||||||
|
{ name: 'traefik', port: 'traefik' },
|
||||||
|
{ name: 'redis', port: 'redis' },
|
||||||
|
{ name: 'nats', port: 'nats' },
|
||||||
|
{ name: 'dapr-placement', port: 'dapr-placement' },
|
||||||
|
{ name: 'scraper-api', port: 'scraper-api' },
|
||||||
|
{ name: 'scraper-api-dapr', port: 'scraper-api-dapr' },
|
||||||
|
{ name: 'otel-collector', port: 'otel-collector' },
|
||||||
|
{ name: 'jaeger', port: 'jaeger' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Try to get services from Jaeger for display
|
||||||
|
let jaegerServices = [];
|
||||||
|
try {
|
||||||
|
const svcRes = await fetchJSON(`${JAEGER_API}/api/services`);
|
||||||
|
jaegerServices = svcRes.data || [];
|
||||||
|
} catch(e) { /* ignore */ }
|
||||||
|
|
||||||
|
// Determine services to display: prefer Jaeger-reported + infrastructure
|
||||||
|
const allNames = new Set([...services.map(s => s.name), ...jaegerServices]);
|
||||||
|
const displayNames = Array.from(allNames).sort();
|
||||||
|
countEl.textContent = displayNames.length;
|
||||||
|
statSvc.textContent = displayNames.length;
|
||||||
|
|
||||||
|
listEl.innerHTML = displayNames.map(name => {
|
||||||
|
// Determine status: Jaeger services are "up", check others via probe
|
||||||
|
// Simple heuristic: green if in Docker services list, yellow for Jaeger-only
|
||||||
|
const infraService = services.find(s => s.name === name);
|
||||||
|
const status = infraService ? 'up' : 'degraded';
|
||||||
|
const badgeClass = status === 'up' ? 'badge-up' : 'badge-degraded';
|
||||||
|
const label = status === 'up' ? 'up' : 'unknown';
|
||||||
|
return `<div class="service-item">
|
||||||
|
<span class="name">${escHtml(name)}</span>
|
||||||
|
<span class="badge ${badgeClass}">${label}</span>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
const now = nowMicro();
|
||||||
|
const lookback = 5 * 60 * 1_000_000;
|
||||||
|
const start = now - lookback;
|
||||||
|
const services = await fetchJSON(`${JAEGER_API}/api/services`);
|
||||||
|
let totalTraces = 0;
|
||||||
|
let totalErrors = 0;
|
||||||
|
let maxDuration = 0;
|
||||||
|
|
||||||
|
for (const svc of (services.data || []).slice(0, 5)) {
|
||||||
|
try {
|
||||||
|
const data = await fetchJSON(
|
||||||
|
`${JAEGER_API}/api/traces?service=${encodeURIComponent(svc)}&start=${start}&end=${now}&limit=50&lookback=5m`
|
||||||
|
);
|
||||||
|
if (data.data) {
|
||||||
|
totalTraces += data.data.length;
|
||||||
|
for (const t of data.data) {
|
||||||
|
if (t.duration > maxDuration) maxDuration = t.duration;
|
||||||
|
if (t.spans && t.spans.some(s => (s.tags || []).some(tg => tg.key === 'error' && tg.value === true))) {
|
||||||
|
totalErrors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e) { /* skip */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('stat-traces').textContent = totalTraces;
|
||||||
|
document.getElementById('stat-errors').textContent = totalErrors;
|
||||||
|
document.getElementById('stat-p99').textContent = maxDuration > 0 ? formatDuration(maxDuration) : '-';
|
||||||
|
} catch(e) { /* ignore - stats may not load */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHealth(status, label) {
|
||||||
|
const el = document.getElementById('health-indicator');
|
||||||
|
el.innerHTML = `<span class="dot" style="background:var(--${status})"></span> ${label}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
loadTraces(),
|
||||||
|
loadServices(),
|
||||||
|
loadStats(),
|
||||||
|
]);
|
||||||
|
setHealth('green', 'All Systems Operational');
|
||||||
|
} catch(e) {
|
||||||
|
setHealth('red', 'Dashboard Error');
|
||||||
|
}
|
||||||
|
document.getElementById('last-updated').textContent = `Updated ${new Date().toLocaleTimeString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
refresh();
|
||||||
|
|
||||||
|
// Auto-refresh every 15s
|
||||||
|
setInterval(refresh, 15000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# CORS headers for API endpoints
|
||||||
|
add_header Access-Control-Allow-Origin *;
|
||||||
|
add_header Access-Control-Allow-Methods "GET, OPTIONS";
|
||||||
|
add_header Access-Control-Allow-Headers "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range";
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options DENY;
|
||||||
|
add_header X-Content-Type-Options nosniff;
|
||||||
|
add_header Referrer-Policy same-origin;
|
||||||
|
|
||||||
|
# Gzip
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/html text/css application/javascript application/json;
|
||||||
|
|
||||||
|
# Jaeger API proxy
|
||||||
|
location /api/jaeger/ {
|
||||||
|
proxy_pass http://jaeger:16686/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_read_timeout 30s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Jaeger UI
|
||||||
|
location /jaeger/ {
|
||||||
|
proxy_pass http://jaeger:16686/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# OTel collector prometheus metrics
|
||||||
|
location /api/metrics {
|
||||||
|
proxy_pass http://otel-collector:8889/metrics;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 10s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# OTel collector health
|
||||||
|
location /api/health {
|
||||||
|
proxy_pass http://otel-collector:8888/healthz;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Static files (including index.html)
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
expires 5s;
|
||||||
|
add_header Cache-Control "public, must-revalidate";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Deny hidden files
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
access_log off;
|
||||||
|
log_not_found off;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_page 404 /index.html;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
FROM nginx:alpine
|
||||||
|
COPY infra/dashboard/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY infra/dashboard/index.html /usr/share/nginx/html/index.html
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# ── NATS Server Configuration ──
|
||||||
|
# JetStream
|
||||||
|
jetstream: true
|
||||||
|
store_dir: /data
|
||||||
|
|
||||||
|
# OpenTelemetry tracing
|
||||||
|
otel:
|
||||||
|
traces:
|
||||||
|
exporter: otlp
|
||||||
|
otlp:
|
||||||
|
endpoint: otel-collector:4318
|
||||||
|
insecure: true
|
||||||
|
|
||||||
|
# HTTP monitoring
|
||||||
|
http_port: 8222
|
||||||
|
|
||||||
|
# Limits
|
||||||
|
max_payload: 1MB
|
||||||
|
max_pending_size: 64MB
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
receivers:
|
||||||
|
otlp:
|
||||||
|
protocols:
|
||||||
|
grpc:
|
||||||
|
endpoint: 0.0.0.0:4317
|
||||||
|
http:
|
||||||
|
endpoint: 0.0.0.0:4318
|
||||||
|
|
||||||
|
exporters:
|
||||||
|
otlp/jaeger:
|
||||||
|
endpoint: jaeger:4317
|
||||||
|
tls:
|
||||||
|
insecure: true
|
||||||
|
|
||||||
|
prometheus:
|
||||||
|
endpoint: 0.0.0.0:8889
|
||||||
|
enable_open_metrics: true
|
||||||
|
resource_to_telemetry_conversion:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
debug:
|
||||||
|
verbosity: normal
|
||||||
|
sampling_initial: 5
|
||||||
|
sampling_thereafter: 100
|
||||||
|
|
||||||
|
processors:
|
||||||
|
batch:
|
||||||
|
timeout: 1s
|
||||||
|
send_batch_size: 1024
|
||||||
|
|
||||||
|
memory_limiter:
|
||||||
|
check_interval: 1s
|
||||||
|
limit_mib: 512
|
||||||
|
spike_limit_mib: 128
|
||||||
|
|
||||||
|
attributes:
|
||||||
|
actions:
|
||||||
|
- key: service.namespace
|
||||||
|
value: asepharyana-hub
|
||||||
|
action: upsert
|
||||||
|
|
||||||
|
service:
|
||||||
|
pipelines:
|
||||||
|
traces:
|
||||||
|
receivers: [otlp]
|
||||||
|
processors: [memory_limiter, batch, attributes]
|
||||||
|
exporters: [otlp/jaeger, debug]
|
||||||
|
metrics:
|
||||||
|
receivers: [otlp]
|
||||||
|
processors: [memory_limiter, batch, attributes]
|
||||||
|
exporters: [prometheus, debug]
|
||||||
@@ -10,8 +10,38 @@ http:
|
|||||||
- common-chain@file
|
- common-chain@file
|
||||||
service: scraper-service
|
service: scraper-service
|
||||||
|
|
||||||
|
# ── Dashboard ──
|
||||||
|
dashboard:
|
||||||
|
rule: 'Host(`dashboard.asepharyana.my.id`) || Host(`status.asepharyana.my.id`) || Host(`dashboard.asepharyana.web.id`) || Host(`status.asepharyana.web.id`)'
|
||||||
|
entryPoints:
|
||||||
|
- websecure
|
||||||
|
tls: {}
|
||||||
|
middlewares:
|
||||||
|
- common-chain@file
|
||||||
|
service: dashboard-service
|
||||||
|
|
||||||
|
# ── Jaeger UI ──
|
||||||
|
jaeger:
|
||||||
|
rule: 'Host(`jaeger.asepharyana.my.id`) || Host(`jaeger.asepharyana.web.id`)'
|
||||||
|
entryPoints:
|
||||||
|
- websecure
|
||||||
|
tls: {}
|
||||||
|
middlewares:
|
||||||
|
- common-chain@file
|
||||||
|
service: jaeger-service
|
||||||
|
|
||||||
services:
|
services:
|
||||||
scraper-service:
|
scraper-service:
|
||||||
loadBalancer:
|
loadBalancer:
|
||||||
servers:
|
servers:
|
||||||
- url: 'http://scraper-api:4091'
|
- url: 'http://scraper-api:4091'
|
||||||
|
|
||||||
|
dashboard-service:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: 'http://dashboard:8080'
|
||||||
|
|
||||||
|
jaeger-service:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: 'http://jaeger:16686'
|
||||||
|
|||||||
Reference in New Issue
Block a user