feat: The Other Dude v9.0.1 — full-featured email system

ci: add GitHub Pages deployment workflow for docs site

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-08 17:46:37 -05:00
commit b840047e19
511 changed files with 106948 additions and 0 deletions

View File

@@ -0,0 +1,55 @@
# Multi-stage build for TOD API
# Stage 1: build — install Python deps
FROM python:3.12-slim AS builder
# Install system dependencies needed for asyncpg (libpq-dev) and cryptography
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev \
gcc \
build-essential \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
# Copy and install Python dependencies first (layer cache optimization)
COPY backend/pyproject.toml ./
# Create a minimal README.md so pip install doesn't fail (pyproject.toml references it)
RUN echo "# TOD API" > README.md
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir --prefix=/install .
# Stage 2: runtime — lean production image
FROM python:3.12-slim AS runtime
# Runtime system deps: libpq for asyncpg, pango/cairo/gdk-pixbuf for weasyprint PDF generation
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libpangoft2-1.0-0 \
libcairo2 \
libgdk-pixbuf-2.0-0 \
libglib2.0-0 \
libffi8 \
&& rm -rf /var/lib/apt/lists/*
# Create a non-root user for security
RUN groupadd --gid 1001 appuser && \
useradd --uid 1001 --gid appuser --no-create-home appuser
WORKDIR /app
# Copy installed packages from builder
COPY --from=builder /install /usr/local
# Copy application source
COPY backend/ .
# Change ownership to non-root user
RUN chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
CMD ["gunicorn", "app.main:app", "--config", "gunicorn.conf.py"]

View File

@@ -0,0 +1,39 @@
# Multi-stage build for TOD Frontend
# Stage 1: build — Node.js build environment
FROM node:18-alpine AS builder
# Cap Node.js heap to 512 MB so tsc + vite build don't OOM a low-RAM server.
# Vite's bundler defaults to using all available heap; on a 2-4 GB machine that
# competes directly with the Go and Python builds happening in parallel.
ENV NODE_OPTIONS="--max-old-space-size=1024"
WORKDIR /build
# Copy package files first (layer cache optimization)
COPY frontend/package.json frontend/package-lock.json* ./
# Install dependencies
RUN npm ci --ignore-scripts
# Copy frontend source and build
# Note: skip tsc type-check in Docker build (esbuild handles transpilation).
# Run `npm run build` locally or in CI for full type-checking.
COPY frontend/ .
RUN npx vite build
# Stage 2: runtime — nginx serving static SPA files
FROM nginx:alpine AS runtime
# Remove default nginx config
RUN rm /etc/nginx/conf.d/default.conf
# Custom nginx config for SPA routing (all routes -> index.html)
COPY infrastructure/docker/nginx-spa.conf /etc/nginx/conf.d/default.conf
# Copy built assets from builder stage
COPY --from=builder /build/dist /usr/share/nginx/html
# nginx runs as nginx user (non-root by convention)
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,56 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Security headers (server-level -- inherited by all locations unless overridden)
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "0" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# CSP for React SPA with Tailwind CSS and Leaflet maps
# worker-src required for SRP key derivation Web Worker (Safari won't fall back to script-src)
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.tile.openstreetmap.org; font-src 'self'; connect-src 'self'; worker-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
# Proxy API requests to the backend service
# The api container is reachable via Docker internal DNS as "api" on port 8000
location /api/ {
proxy_pass http://api:8000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Disable buffering for streaming/SSE endpoints
proxy_buffering off;
proxy_read_timeout 300s;
# Hide nginx server-level CSP — backend sets its own CSP on API responses
proxy_hide_header Content-Security-Policy;
}
# Serve static assets with long cache headers
# Note: add_header in a location block clears parent-block headers,
# so we re-add the essential security header for static assets.
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable" always;
add_header X-Content-Type-Options "nosniff" always;
try_files $uri =404;
}
# SPA routing: all other requests serve index.html
location / {
try_files $uri $uri/ /index.html;
}
# Health check endpoint for container probes
location /nginx-health {
access_log off;
return 200 "OK\n";
add_header Content-Type text/plain;
}
}

View File

@@ -0,0 +1,13 @@
apiVersion: v2
name: tod
description: The Other Dude — MikroTik Fleet Management
type: application
version: 9.0.1
appVersion: "9.0.1"
keywords:
- mikrotik
- network-management
- fleet-management
home: https://theotherdude.net
maintainers:
- name: The Other Dude Team

View File

@@ -0,0 +1,171 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "mikrotik-portal.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "mikrotik-portal.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "mikrotik-portal.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels applied to all resources.
*/}}
{{- define "mikrotik-portal.labels" -}}
helm.sh/chart: {{ include "mikrotik-portal.chart" . }}
{{ include "mikrotik-portal.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels used in Deployments/Services to match pods.
*/}}
{{- define "mikrotik-portal.selectorLabels" -}}
app.kubernetes.io/name: {{ include "mikrotik-portal.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
API component labels
*/}}
{{- define "mikrotik-portal.apiLabels" -}}
{{ include "mikrotik-portal.labels" . }}
app.kubernetes.io/component: api
{{- end }}
{{/*
API selector labels
*/}}
{{- define "mikrotik-portal.apiSelectorLabels" -}}
{{ include "mikrotik-portal.selectorLabels" . }}
app.kubernetes.io/component: api
{{- end }}
{{/*
Frontend component labels
*/}}
{{- define "mikrotik-portal.frontendLabels" -}}
{{ include "mikrotik-portal.labels" . }}
app.kubernetes.io/component: frontend
{{- end }}
{{/*
Frontend selector labels
*/}}
{{- define "mikrotik-portal.frontendSelectorLabels" -}}
{{ include "mikrotik-portal.selectorLabels" . }}
app.kubernetes.io/component: frontend
{{- end }}
{{/*
PostgreSQL component labels
*/}}
{{- define "mikrotik-portal.postgresLabels" -}}
{{ include "mikrotik-portal.labels" . }}
app.kubernetes.io/component: postgres
{{- end }}
{{/*
PostgreSQL selector labels
*/}}
{{- define "mikrotik-portal.postgresSelectorLabels" -}}
{{ include "mikrotik-portal.selectorLabels" . }}
app.kubernetes.io/component: postgres
{{- end }}
{{/*
Redis component labels
*/}}
{{- define "mikrotik-portal.redisLabels" -}}
{{ include "mikrotik-portal.labels" . }}
app.kubernetes.io/component: redis
{{- end }}
{{/*
Redis selector labels
*/}}
{{- define "mikrotik-portal.redisSelectorLabels" -}}
{{ include "mikrotik-portal.selectorLabels" . }}
app.kubernetes.io/component: redis
{{- end }}
{{/*
Create the name of the service account to use.
*/}}
{{- define "mikrotik-portal.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "mikrotik-portal.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
{{/*
Database URL for the API service (constructed from service names).
Uses external URL if postgres.enabled=false.
*/}}
{{- define "mikrotik-portal.databaseUrl" -}}
{{- if .Values.postgres.enabled }}
{{- printf "postgresql+asyncpg://%s:%s@%s-postgres:%d/%s" .Values.postgres.auth.username .Values.secrets.dbPassword (include "mikrotik-portal.fullname" .) (int .Values.postgres.service.port) .Values.postgres.auth.database }}
{{- else }}
{{- .Values.postgres.externalUrl }}
{{- end }}
{{- end }}
{{/*
App user database URL (RLS enforced).
*/}}
{{- define "mikrotik-portal.appUserDatabaseUrl" -}}
{{- if .Values.postgres.enabled }}
{{- printf "postgresql+asyncpg://%s:%s@%s-postgres:%d/%s" .Values.postgres.auth.appUsername .Values.secrets.dbAppPassword (include "mikrotik-portal.fullname" .) (int .Values.postgres.service.port) .Values.postgres.auth.database }}
{{- else }}
{{- .Values.postgres.externalUrl }}
{{- end }}
{{- end }}
{{/*
Sync database URL for Alembic migrations.
*/}}
{{- define "mikrotik-portal.syncDatabaseUrl" -}}
{{- if .Values.postgres.enabled }}
{{- printf "postgresql+psycopg2://%s:%s@%s-postgres:%d/%s" .Values.postgres.auth.username .Values.secrets.dbPassword (include "mikrotik-portal.fullname" .) (int .Values.postgres.service.port) .Values.postgres.auth.database }}
{{- else }}
{{- .Values.postgres.externalUrl | replace "asyncpg" "psycopg2" }}
{{- end }}
{{- end }}
{{/*
Redis URL (constructed from service name).
*/}}
{{- define "mikrotik-portal.redisUrl" -}}
{{- if .Values.redis.enabled }}
{{- printf "redis://%s-redis:%d/0" (include "mikrotik-portal.fullname" .) (int .Values.redis.service.port) }}
{{- else }}
{{- .Values.redis.externalUrl | default "redis://localhost:6379/0" }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,76 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "mikrotik-portal.fullname" . }}-api
labels:
{{- include "mikrotik-portal.apiLabels" . | nindent 4 }}
spec:
replicas: {{ .Values.api.replicaCount }}
selector:
matchLabels:
{{- include "mikrotik-portal.apiSelectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "mikrotik-portal.apiSelectorLabels" . | nindent 8 }}
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
containers:
- name: api
image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag }}"
imagePullPolicy: {{ .Values.api.image.pullPolicy }}
ports:
- name: http
containerPort: 8000
protocol: TCP
# Load non-sensitive config from ConfigMap
envFrom:
- configMapRef:
name: {{ include "mikrotik-portal.fullname" . }}-config
# Load secrets as individual environment variables
env:
- name: JWT_SECRET_KEY
valueFrom:
secretKeyRef:
name: {{ include "mikrotik-portal.fullname" . }}-secrets
key: JWT_SECRET_KEY
- name: CREDENTIAL_ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: {{ include "mikrotik-portal.fullname" . }}-secrets
key: CREDENTIAL_ENCRYPTION_KEY
- name: FIRST_ADMIN_EMAIL
valueFrom:
secretKeyRef:
name: {{ include "mikrotik-portal.fullname" . }}-secrets
key: FIRST_ADMIN_EMAIL
- name: FIRST_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "mikrotik-portal.fullname" . }}-secrets
key: FIRST_ADMIN_PASSWORD
livenessProbe:
httpGet:
path: {{ .Values.api.probes.liveness.path }}
port: http
initialDelaySeconds: {{ .Values.api.probes.liveness.initialDelaySeconds }}
periodSeconds: {{ .Values.api.probes.liveness.periodSeconds }}
failureThreshold: {{ .Values.api.probes.liveness.failureThreshold }}
readinessProbe:
httpGet:
path: {{ .Values.api.probes.readiness.path }}
port: http
initialDelaySeconds: {{ .Values.api.probes.readiness.initialDelaySeconds }}
periodSeconds: {{ .Values.api.probes.readiness.periodSeconds }}
failureThreshold: {{ .Values.api.probes.readiness.failureThreshold }}
resources:
{{- toYaml .Values.api.resources | nindent 12 }}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
capabilities:
drop:
- ALL

View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "mikrotik-portal.fullname" . }}-api
labels:
{{- include "mikrotik-portal.apiLabels" . | nindent 4 }}
spec:
type: {{ .Values.api.service.type }}
ports:
- port: {{ .Values.api.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "mikrotik-portal.apiSelectorLabels" . | nindent 4 }}

View File

@@ -0,0 +1,21 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "mikrotik-portal.fullname" . }}-config
labels:
{{- include "mikrotik-portal.labels" . | nindent 4 }}
data:
DATABASE_URL: {{ include "mikrotik-portal.databaseUrl" . | quote }}
SYNC_DATABASE_URL: {{ include "mikrotik-portal.syncDatabaseUrl" . | quote }}
APP_USER_DATABASE_URL: {{ include "mikrotik-portal.appUserDatabaseUrl" . | quote }}
REDIS_URL: {{ include "mikrotik-portal.redisUrl" . | quote }}
NATS_URL: {{ printf "nats://%s-nats:%d" (include "mikrotik-portal.fullname" .) (int .Values.nats.service.port) | quote }}
JWT_ALGORITHM: "HS256"
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: {{ .Values.api.env.jwtAccessTokenExpireMinutes | quote }}
JWT_REFRESH_TOKEN_EXPIRE_DAYS: {{ .Values.api.env.jwtRefreshTokenExpireDays | quote }}
CORS_ORIGINS: {{ .Values.api.env.corsOrigins | quote }}
DEBUG: {{ .Values.api.env.debug | quote }}
APP_NAME: "TOD - The Other Dude"
APP_VERSION: {{ .Chart.AppVersion | quote }}
POLL_INTERVAL_SECONDS: {{ .Values.poller.env.pollIntervalSeconds | quote }}
POLLER_LOG_LEVEL: {{ .Values.poller.env.logLevel | quote }}

View File

@@ -0,0 +1,56 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "mikrotik-portal.fullname" . }}-frontend
labels:
{{- include "mikrotik-portal.frontendLabels" . | nindent 4 }}
spec:
replicas: {{ .Values.frontend.replicaCount }}
selector:
matchLabels:
{{- include "mikrotik-portal.frontendSelectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "mikrotik-portal.frontendSelectorLabels" . | nindent 8 }}
spec:
containers:
- name: frontend
image: "{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag }}"
imagePullPolicy: {{ .Values.frontend.image.pullPolicy }}
ports:
- name: http
containerPort: 80
protocol: TCP
resources:
{{- toYaml .Values.frontend.resources | nindent 12 }}
livenessProbe:
httpGet:
path: /nginx-health
port: http
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /nginx-health
port: http
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 3
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "mikrotik-portal.fullname" . }}-frontend
labels:
{{- include "mikrotik-portal.frontendLabels" . | nindent 4 }}
spec:
type: {{ .Values.frontend.service.type }}
ports:
- port: {{ .Values.frontend.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "mikrotik-portal.frontendSelectorLabels" . | nindent 4 }}

View File

@@ -0,0 +1,57 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "mikrotik-portal.fullname" . }}
labels:
{{- include "mikrotik-portal.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls.enabled }}
tls:
- hosts:
- {{ .Values.ingress.host | default "mikrotik-portal.local" | quote }}
secretName: {{ .Values.ingress.tls.secretName | default (printf "%s-tls" (include "mikrotik-portal.fullname" .)) | quote }}
{{- end }}
rules:
- host: {{ .Values.ingress.host | default "mikrotik-portal.local" | quote }}
http:
paths:
# API routes — send /api/* to the FastAPI service
- path: /api
pathType: Prefix
backend:
service:
name: {{ include "mikrotik-portal.fullname" . }}-api
port:
number: {{ .Values.api.service.port }}
# Docs routes — proxy /docs and /redoc to API as well
- path: /docs
pathType: Prefix
backend:
service:
name: {{ include "mikrotik-portal.fullname" . }}-api
port:
number: {{ .Values.api.service.port }}
- path: /redoc
pathType: Prefix
backend:
service:
name: {{ include "mikrotik-portal.fullname" . }}-api
port:
number: {{ .Values.api.service.port }}
# Frontend SPA — all other routes go to nginx
- path: /
pathType: Prefix
backend:
service:
name: {{ include "mikrotik-portal.fullname" . }}-frontend
port:
number: {{ .Values.frontend.service.port }}
{{- end }}

View File

@@ -0,0 +1,115 @@
{{- if .Values.nats.enabled }}
---
# NATS headless service for StatefulSet DNS
apiVersion: v1
kind: Service
metadata:
name: {{ include "mikrotik-portal.fullname" . }}-nats-headless
labels:
{{- include "mikrotik-portal.labels" . | nindent 4 }}
app.kubernetes.io/component: nats
spec:
clusterIP: None
ports:
- name: client
port: 4222
targetPort: 4222
selector:
{{- include "mikrotik-portal.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: nats
---
# NATS ClusterIP service for client access
apiVersion: v1
kind: Service
metadata:
name: {{ include "mikrotik-portal.fullname" . }}-nats
labels:
{{- include "mikrotik-portal.labels" . | nindent 4 }}
app.kubernetes.io/component: nats
spec:
type: ClusterIP
ports:
- name: client
port: {{ .Values.nats.service.port }}
targetPort: 4222
- name: monitoring
port: 8222
targetPort: 8222
selector:
{{- include "mikrotik-portal.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: nats
---
# NATS JetStream StatefulSet (needs stable storage)
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ include "mikrotik-portal.fullname" . }}-nats
labels:
{{- include "mikrotik-portal.labels" . | nindent 4 }}
app.kubernetes.io/component: nats
spec:
replicas: 1
serviceName: {{ include "mikrotik-portal.fullname" . }}-nats-headless
selector:
matchLabels:
{{- include "mikrotik-portal.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: nats
template:
metadata:
labels:
{{- include "mikrotik-portal.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: nats
spec:
containers:
- name: nats
image: "{{ .Values.nats.image.repository }}:{{ .Values.nats.image.tag }}"
imagePullPolicy: {{ .Values.nats.image.pullPolicy }}
args:
- "-js"
- "--store_dir"
- "/data"
- "-m"
- "8222"
ports:
- name: client
containerPort: 4222
protocol: TCP
- name: monitoring
containerPort: 8222
protocol: TCP
livenessProbe:
httpGet:
path: /healthz
port: 8222
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /healthz
port: 8222
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: {{ .Values.nats.resources.requests.cpu }}
memory: {{ .Values.nats.resources.requests.memory }}
limits:
cpu: {{ .Values.nats.resources.limits.cpu }}
memory: {{ .Values.nats.resources.limits.memory }}
volumeMounts:
- name: nats-data
mountPath: /data
volumeClaimTemplates:
- metadata:
name: nats-data
spec:
accessModes: ["ReadWriteOnce"]
{{- if .Values.nats.storageClass }}
storageClassName: {{ .Values.nats.storageClass | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.nats.storage }}
{{- end }}

View File

@@ -0,0 +1,62 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "mikrotik-portal.fullname" . }}-poller
labels:
{{- include "mikrotik-portal.labels" . | nindent 4 }}
app.kubernetes.io/component: poller
spec:
replicas: {{ .Values.poller.replicaCount }}
selector:
matchLabels:
{{- include "mikrotik-portal.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: poller
template:
metadata:
labels:
{{- include "mikrotik-portal.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: poller
spec:
containers:
- name: poller
image: "{{ .Values.poller.image.repository }}:{{ .Values.poller.image.tag }}"
imagePullPolicy: {{ .Values.poller.image.pullPolicy }}
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ include "mikrotik-portal.fullname" . }}-secrets
key: POLLER_DATABASE_URL
- name: CREDENTIAL_ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: {{ include "mikrotik-portal.fullname" . }}-secrets
key: CREDENTIAL_ENCRYPTION_KEY
- name: NATS_URL
valueFrom:
configMapKeyRef:
name: {{ include "mikrotik-portal.fullname" . }}-config
key: NATS_URL
- name: REDIS_URL
valueFrom:
configMapKeyRef:
name: {{ include "mikrotik-portal.fullname" . }}-config
key: REDIS_URL
- name: POLL_INTERVAL_SECONDS
valueFrom:
configMapKeyRef:
name: {{ include "mikrotik-portal.fullname" . }}-config
key: POLL_INTERVAL_SECONDS
- name: LOG_LEVEL
valueFrom:
configMapKeyRef:
name: {{ include "mikrotik-portal.fullname" . }}-config
key: POLLER_LOG_LEVEL
resources:
requests:
cpu: {{ .Values.poller.resources.requests.cpu }}
memory: {{ .Values.poller.resources.requests.memory }}
limits:
cpu: {{ .Values.poller.resources.limits.cpu }}
memory: {{ .Values.poller.resources.limits.memory }}

View File

@@ -0,0 +1,137 @@
{{- if .Values.postgres.enabled }}
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ include "mikrotik-portal.fullname" . }}-postgres
labels:
{{- include "mikrotik-portal.postgresLabels" . | nindent 4 }}
spec:
serviceName: {{ include "mikrotik-portal.fullname" . }}-postgres
replicas: 1
selector:
matchLabels:
{{- include "mikrotik-portal.postgresSelectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "mikrotik-portal.postgresSelectorLabels" . | nindent 8 }}
spec:
containers:
- name: postgres
image: "{{ .Values.postgres.image.repository }}:{{ .Values.postgres.image.tag }}"
imagePullPolicy: {{ .Values.postgres.image.pullPolicy }}
ports:
- name: postgres
containerPort: 5432
protocol: TCP
env:
- name: POSTGRES_DB
value: {{ .Values.postgres.auth.database | quote }}
- name: POSTGRES_USER
value: {{ .Values.postgres.auth.username | quote }}
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "mikrotik-portal.fullname" . }}-secrets
key: DB_PASSWORD
- name: APP_USER
value: {{ .Values.postgres.auth.appUsername | quote }}
- name: APP_USER_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "mikrotik-portal.fullname" . }}-secrets
key: DB_APP_PASSWORD
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
- name: init-scripts
mountPath: /docker-entrypoint-initdb.d
readOnly: true
resources:
{{- toYaml .Values.postgres.resources | nindent 12 }}
livenessProbe:
exec:
command:
- pg_isready
- -U
- {{ .Values.postgres.auth.username | quote }}
- -d
- {{ .Values.postgres.auth.database | quote }}
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 5
readinessProbe:
exec:
command:
- pg_isready
- -U
- {{ .Values.postgres.auth.username | quote }}
- -d
- {{ .Values.postgres.auth.database | quote }}
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 3
volumes:
- name: init-scripts
configMap:
name: {{ include "mikrotik-portal.fullname" . }}-postgres-init
volumeClaimTemplates:
- metadata:
name: postgres-data
spec:
accessModes: ["ReadWriteOnce"]
{{- if .Values.postgres.storageClass }}
storageClassName: {{ .Values.postgres.storageClass | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.postgres.storage }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "mikrotik-portal.fullname" . }}-postgres
labels:
{{- include "mikrotik-portal.postgresLabels" . | nindent 4 }}
spec:
type: ClusterIP
clusterIP: None
ports:
- port: {{ .Values.postgres.service.port }}
targetPort: postgres
protocol: TCP
name: postgres
selector:
{{- include "mikrotik-portal.postgresSelectorLabels" . | nindent 4 }}
---
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "mikrotik-portal.fullname" . }}-postgres-init
labels:
{{- include "mikrotik-portal.postgresLabels" . | nindent 4 }}
data:
init.sql: |
-- Create non-superuser app_user role for RLS enforcement
-- This runs on first container start via docker-entrypoint-initdb.d
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '{{ .Values.postgres.auth.appUsername }}') THEN
CREATE ROLE {{ .Values.postgres.auth.appUsername }} WITH LOGIN PASSWORD '{{ .Values.secrets.dbAppPassword }}';
END IF;
END $$;
-- Grant connection and usage permissions
GRANT CONNECT ON DATABASE {{ .Values.postgres.auth.database }} TO {{ .Values.postgres.auth.appUsername }};
GRANT USAGE ON SCHEMA public TO {{ .Values.postgres.auth.appUsername }};
-- Grant DML permissions (INSERT, SELECT, UPDATE, DELETE — no DDL)
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO {{ .Values.postgres.auth.appUsername }};
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO {{ .Values.postgres.auth.appUsername }};
-- Set default privileges so future tables are also accessible
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO {{ .Values.postgres.auth.appUsername }};
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO {{ .Values.postgres.auth.appUsername }};
{{- end }}

View File

@@ -0,0 +1,60 @@
{{- if .Values.redis.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "mikrotik-portal.fullname" . }}-redis
labels:
{{- include "mikrotik-portal.redisLabels" . | nindent 4 }}
spec:
replicas: 1
selector:
matchLabels:
{{- include "mikrotik-portal.redisSelectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "mikrotik-portal.redisSelectorLabels" . | nindent 8 }}
spec:
containers:
- name: redis
image: "{{ .Values.redis.image.repository }}:{{ .Values.redis.image.tag }}"
imagePullPolicy: {{ .Values.redis.image.pullPolicy }}
ports:
- name: redis
containerPort: 6379
protocol: TCP
resources:
{{- toYaml .Values.redis.resources | nindent 12 }}
livenessProbe:
exec:
command:
- redis-cli
- ping
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 3
readinessProbe:
exec:
command:
- redis-cli
- ping
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 3
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "mikrotik-portal.fullname" . }}-redis
labels:
{{- include "mikrotik-portal.redisLabels" . | nindent 4 }}
spec:
type: ClusterIP
ports:
- port: {{ .Values.redis.service.port }}
targetPort: redis
protocol: TCP
name: redis
selector:
{{- include "mikrotik-portal.redisSelectorLabels" . | nindent 4 }}
{{- end }}

View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: Secret
metadata:
name: {{ include "mikrotik-portal.fullname" . }}-secrets
labels:
{{- include "mikrotik-portal.labels" . | nindent 4 }}
type: Opaque
stringData:
JWT_SECRET_KEY: {{ .Values.secrets.jwtSecretKey | quote }}
CREDENTIAL_ENCRYPTION_KEY: {{ .Values.secrets.credentialEncryptionKey | quote }}
FIRST_ADMIN_EMAIL: {{ .Values.secrets.firstAdminEmail | quote }}
FIRST_ADMIN_PASSWORD: {{ .Values.secrets.firstAdminPassword | quote }}
DB_PASSWORD: {{ .Values.secrets.dbPassword | quote }}
DB_APP_PASSWORD: {{ .Values.secrets.dbAppPassword | quote }}
POLLER_DATABASE_URL: {{ printf "postgres://poller_user:%s@%s-postgres:%d/%s" .Values.secrets.dbPollerPassword (include "mikrotik-portal.fullname" .) (int .Values.postgres.service.port) .Values.postgres.auth.database | quote }}

View File

@@ -0,0 +1,219 @@
# Default values for mikrotik-portal.
# These values should work with `helm install` out of the box for development.
# Production deployments MUST override secrets.jwtSecretKey, secrets.credentialEncryptionKey,
# and secrets.firstAdminPassword.
# -----------------------------------------------------------------------
# API service
# -----------------------------------------------------------------------
api:
replicaCount: 1
image:
repository: mikrotik-portal/api
tag: latest
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 8000
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
# Liveness and readiness probe configuration
probes:
liveness:
path: /api/health
initialDelaySeconds: 15
periodSeconds: 10
failureThreshold: 3
readiness:
path: /api/health
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 3
env:
# Token expiry (minutes for access, days for refresh)
jwtAccessTokenExpireMinutes: 15
jwtRefreshTokenExpireDays: 7
# CORS — set to your frontend origin in production
corsOrigins: "http://localhost:3000,http://localhost:5173"
debug: "false"
# -----------------------------------------------------------------------
# Frontend service
# -----------------------------------------------------------------------
frontend:
replicaCount: 1
image:
repository: mikrotik-portal/frontend
tag: latest
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 80
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
# -----------------------------------------------------------------------
# PostgreSQL (TimescaleDB)
# -----------------------------------------------------------------------
postgres:
# Set to false to use an external PostgreSQL instance (provide externalUrl below)
enabled: true
image:
repository: timescale/timescaledb
tag: latest-pg17
pullPolicy: IfNotPresent
# Storage for the PVC
storage: 10Gi
storageClass: "" # leave empty to use cluster default StorageClass
service:
port: 5432
auth:
database: mikrotik
username: postgres
# password is sourced from secrets.dbPassword
appUsername: app_user
# appPassword is sourced from secrets.dbAppPassword
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: 1000m
memory: 2Gi
# External PostgreSQL URL (used when postgres.enabled=false)
# externalUrl: "postgresql+asyncpg://user:pass@host:5432/mikrotik"
# -----------------------------------------------------------------------
# Redis
# -----------------------------------------------------------------------
redis:
enabled: true
image:
repository: redis
tag: 7-alpine
pullPolicy: IfNotPresent
service:
port: 6379
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 256Mi
# -----------------------------------------------------------------------
# NATS JetStream
# -----------------------------------------------------------------------
nats:
enabled: true
image:
repository: nats
tag: 2-alpine
pullPolicy: IfNotPresent
storage: 5Gi
storageClass: "" # leave empty to use cluster default StorageClass
service:
port: 4222
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 200m
memory: 512Mi
# -----------------------------------------------------------------------
# Go Poller
# -----------------------------------------------------------------------
poller:
replicaCount: 2
image:
repository: mikrotik-portal/poller
tag: latest
pullPolicy: IfNotPresent
env:
pollIntervalSeconds: 60
logLevel: info
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 256Mi
# -----------------------------------------------------------------------
# Ingress
# -----------------------------------------------------------------------
ingress:
enabled: true
className: nginx
# annotations:
# cert-manager.io/cluster-issuer: letsencrypt-prod
# host: mikrotik.example.com — set this in your deployment
host: ""
tls:
enabled: false
# secretName: mikrotik-portal-tls
# -----------------------------------------------------------------------
# Secrets
# IMPORTANT: All secrets below MUST be overridden in production.
# -----------------------------------------------------------------------
secrets:
# JWT signing key — generate with: openssl rand -hex 32
jwtSecretKey: ""
# AES-256-GCM credential encryption key (base64-encoded 32 bytes)
# Generate with: python -c "import secrets, base64; print(base64.b64encode(secrets.token_bytes(32)).decode())"
credentialEncryptionKey: ""
# First admin account (created on first startup)
firstAdminEmail: "admin@mikrotik-portal.local"
firstAdminPassword: ""
# PostgreSQL superuser password
dbPassword: "postgres"
# app_user password (non-superuser, RLS-enforced)
dbAppPassword: "app_password"
# poller_user password (bypasses RLS — SELECT on devices only)
dbPollerPassword: "poller_password"

View File

@@ -0,0 +1,258 @@
{
"id": null,
"uid": "mikrotik-api-overview",
"title": "API Overview",
"description": "TOD API request rates, error rates, and response times",
"tags": ["mikrotik", "api"],
"timezone": "browser",
"schemaVersion": 39,
"version": 1,
"refresh": "30s",
"editable": true,
"time": {
"from": "now-1h",
"to": "now"
},
"panels": [
{
"id": 1,
"title": "Request Rate (req/s)",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"datasource": "Prometheus",
"targets": [
{
"expr": "sum(rate(http_requests_total[5m])) by (handler)",
"legendFormat": "{{handler}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "reqps",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 10
}
},
"overrides": []
},
"options": {
"tooltip": { "mode": "multi" },
"legend": { "displayMode": "table", "placement": "bottom", "calcs": ["mean", "max"] }
}
},
{
"id": 2,
"title": "Error Rate (4xx/5xx)",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"datasource": "Prometheus",
"targets": [
{
"expr": "sum(rate(http_requests_total{status=~\"4..\"}[5m])) by (handler)",
"legendFormat": "4xx {{handler}}",
"refId": "A"
},
{
"expr": "sum(rate(http_requests_total{status=~\"5..\"}[5m])) by (handler)",
"legendFormat": "5xx {{handler}}",
"refId": "B"
}
],
"fieldConfig": {
"defaults": {
"unit": "reqps",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 10
},
"color": { "mode": "palette-classic" }
},
"overrides": []
},
"options": {
"tooltip": { "mode": "multi" },
"legend": { "displayMode": "table", "placement": "bottom", "calcs": ["mean", "max"] }
}
},
{
"id": 3,
"title": "Response Time P50 (seconds)",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
"datasource": "Prometheus",
"targets": [
{
"expr": "histogram_quantile(0.50, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, handler))",
"legendFormat": "p50 {{handler}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "s",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 10
}
},
"overrides": []
},
"options": {
"tooltip": { "mode": "multi" },
"legend": { "displayMode": "table", "placement": "bottom", "calcs": ["mean", "max"] }
}
},
{
"id": 4,
"title": "Response Time P95 (seconds)",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
"datasource": "Prometheus",
"targets": [
{
"expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, handler))",
"legendFormat": "p95 {{handler}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "s",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 10
}
},
"overrides": []
},
"options": {
"tooltip": { "mode": "multi" },
"legend": { "displayMode": "table", "placement": "bottom", "calcs": ["mean", "max"] }
}
},
{
"id": 5,
"title": "Response Time P99 (seconds)",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 },
"datasource": "Prometheus",
"targets": [
{
"expr": "histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, handler))",
"legendFormat": "p99 {{handler}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "s",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 10
}
},
"overrides": []
},
"options": {
"tooltip": { "mode": "multi" },
"legend": { "displayMode": "table", "placement": "bottom", "calcs": ["mean", "max"] }
}
},
{
"id": 6,
"title": "Requests In Progress",
"type": "gauge",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 },
"datasource": "Prometheus",
"targets": [
{
"expr": "sum(http_requests_in_progress)",
"legendFormat": "In Progress",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 10 },
{ "color": "red", "value": 50 }
]
},
"min": 0,
"max": 100
},
"overrides": []
},
"options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"showThresholdLabels": false,
"showThresholdMarkers": true
}
},
{
"id": 7,
"title": "Status Code Distribution (1h)",
"type": "piechart",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 24 },
"datasource": "Prometheus",
"targets": [
{
"expr": "sum by(status) (increase(http_requests_total[1h]))",
"legendFormat": "{{status}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {},
"overrides": []
},
"options": {
"legend": { "displayMode": "table", "placement": "right", "values": ["value", "percent"] },
"pieType": "donut",
"reduceOptions": { "calcs": ["lastNotNull"] }
}
},
{
"id": 8,
"title": "Top Endpoints (1h)",
"type": "table",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 24 },
"datasource": "Prometheus",
"targets": [
{
"expr": "topk(10, sum by(handler) (increase(http_requests_total[1h])))",
"legendFormat": "{{handler}}",
"refId": "A",
"format": "table",
"instant": true
}
],
"fieldConfig": {
"defaults": {},
"overrides": [
{
"matcher": { "id": "byName", "options": "Value" },
"properties": [{ "id": "displayName", "value": "Requests (1h)" }]
}
]
},
"options": {
"showHeader": true,
"sortBy": [{ "desc": true, "displayName": "Requests (1h)" }]
},
"transformations": [
{ "id": "organize", "options": { "excludeByName": { "Time": true } } }
]
}
]
}

View File

@@ -0,0 +1,222 @@
{
"id": null,
"uid": "mikrotik-infrastructure",
"title": "Infrastructure",
"description": "TOD service health and availability overview",
"tags": ["mikrotik", "infrastructure"],
"timezone": "browser",
"schemaVersion": 39,
"version": 1,
"refresh": "30s",
"editable": true,
"time": {
"from": "now-1h",
"to": "now"
},
"panels": [
{
"id": 1,
"title": "API Up",
"type": "stat",
"gridPos": { "h": 5, "w": 6, "x": 0, "y": 0 },
"datasource": "Prometheus",
"targets": [
{
"expr": "up{job=\"mikrotik-api\"}",
"legendFormat": "API",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"mappings": [
{ "type": "value", "options": { "0": { "text": "DOWN", "color": "red" }, "1": { "text": "UP", "color": "green" } } }
],
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "green", "value": 1 }
]
}
},
"overrides": []
},
"options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"colorMode": "background",
"graphMode": "none",
"textMode": "auto"
}
},
{
"id": 2,
"title": "Poller Up",
"type": "stat",
"gridPos": { "h": 5, "w": 6, "x": 6, "y": 0 },
"datasource": "Prometheus",
"targets": [
{
"expr": "up{job=\"mikrotik-poller\"}",
"legendFormat": "Poller",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"mappings": [
{ "type": "value", "options": { "0": { "text": "DOWN", "color": "red" }, "1": { "text": "UP", "color": "green" } } }
],
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "green", "value": 1 }
]
}
},
"overrides": []
},
"options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"colorMode": "background",
"graphMode": "none",
"textMode": "auto"
}
},
{
"id": 3,
"title": "API Requests/s",
"type": "stat",
"gridPos": { "h": 5, "w": 6, "x": 12, "y": 0 },
"datasource": "Prometheus",
"targets": [
{
"expr": "sum(rate(http_requests_total[5m]))",
"legendFormat": "Requests/s",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "reqps",
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "blue", "value": null }
]
}
},
"overrides": []
},
"options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"colorMode": "value",
"graphMode": "area"
}
},
{
"id": 4,
"title": "Polls/s",
"type": "stat",
"gridPos": { "h": 5, "w": 6, "x": 18, "y": 0 },
"datasource": "Prometheus",
"targets": [
{
"expr": "sum(rate(mikrotik_poll_total[5m]))",
"legendFormat": "Polls/s",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "ops",
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "blue", "value": null }
]
}
},
"overrides": []
},
"options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"colorMode": "value",
"graphMode": "area"
}
},
{
"id": 5,
"title": "Service Availability",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 5 },
"datasource": "Prometheus",
"targets": [
{
"expr": "up",
"legendFormat": "{{job}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"min": 0,
"max": 1,
"custom": {
"drawStyle": "line",
"lineInterpolation": "stepAfter",
"fillOpacity": 30,
"pointSize": 5,
"showPoints": "always"
},
"mappings": [
{ "type": "value", "options": { "0": { "text": "DOWN" }, "1": { "text": "UP" } } }
]
},
"overrides": []
},
"options": {
"tooltip": { "mode": "multi" },
"legend": { "displayMode": "list", "placement": "bottom" }
}
},
{
"id": 6,
"title": "API 5xx Error Rate",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 5 },
"datasource": "Prometheus",
"targets": [
{
"expr": "sum(rate(http_requests_total{status=~\"5..\"}[5m]))",
"legendFormat": "5xx errors/s",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "reqps",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 20
},
"color": { "mode": "fixed", "fixedColor": "red" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "red", "value": 0.1 }
]
}
},
"overrides": []
},
"options": {
"tooltip": { "mode": "single" },
"legend": { "displayMode": "list", "placement": "bottom" }
}
}
]
}

View File

@@ -0,0 +1,324 @@
{
"id": null,
"uid": "mikrotik-poller-status",
"title": "Poller Status",
"description": "MikroTik Poller metrics: active devices, poll duration, error rates",
"tags": ["mikrotik", "poller"],
"timezone": "browser",
"schemaVersion": 39,
"version": 1,
"refresh": "30s",
"editable": true,
"time": {
"from": "now-1h",
"to": "now"
},
"panels": [
{
"id": 1,
"title": "Active Devices",
"type": "stat",
"gridPos": { "h": 5, "w": 6, "x": 0, "y": 0 },
"datasource": "Prometheus",
"targets": [
{
"expr": "mikrotik_devices_active",
"legendFormat": "Devices",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null }
]
}
},
"overrides": []
},
"options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"textMode": "auto"
}
},
{
"id": 2,
"title": "Poll Success Rate %",
"type": "stat",
"gridPos": { "h": 5, "w": 6, "x": 6, "y": 0 },
"datasource": "Prometheus",
"targets": [
{
"expr": "rate(mikrotik_poll_total{status=\"success\"}[5m]) / (rate(mikrotik_poll_total[5m]) > 0) * 100",
"legendFormat": "Success %",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "yellow", "value": 80 },
{ "color": "green", "value": 95 }
]
}
},
"overrides": []
},
"options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"colorMode": "value",
"graphMode": "none"
}
},
{
"id": 3,
"title": "Total Polls/s",
"type": "stat",
"gridPos": { "h": 5, "w": 6, "x": 12, "y": 0 },
"datasource": "Prometheus",
"targets": [
{
"expr": "sum(rate(mikrotik_poll_total[5m]))",
"legendFormat": "Polls/s",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "ops",
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "blue", "value": null }
]
}
},
"overrides": []
},
"options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"colorMode": "value",
"graphMode": "area"
}
},
{
"id": 4,
"title": "Connection Errors/s",
"type": "stat",
"gridPos": { "h": 5, "w": 6, "x": 18, "y": 0 },
"datasource": "Prometheus",
"targets": [
{
"expr": "rate(mikrotik_device_connection_errors_total[5m])",
"legendFormat": "Errors/s",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "ops",
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.1 },
{ "color": "red", "value": 1 }
]
}
},
"overrides": []
},
"options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"colorMode": "value",
"graphMode": "area"
}
},
{
"id": 5,
"title": "Avg Poll Duration (seconds)",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 5 },
"datasource": "Prometheus",
"targets": [
{
"expr": "rate(mikrotik_poll_duration_seconds_sum[5m]) / rate(mikrotik_poll_duration_seconds_count[5m])",
"legendFormat": "Avg Duration",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "s",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 20
},
"color": { "mode": "fixed", "fixedColor": "blue" }
},
"overrides": []
},
"options": {
"tooltip": { "mode": "single" },
"legend": { "displayMode": "list", "placement": "bottom" }
}
},
{
"id": 6,
"title": "Poll Duration Distribution",
"type": "heatmap",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 5 },
"datasource": "Prometheus",
"targets": [
{
"expr": "sum(increase(mikrotik_poll_duration_seconds_bucket[5m])) by (le)",
"legendFormat": "{{le}}",
"refId": "A",
"format": "heatmap"
}
],
"fieldConfig": {
"defaults": {},
"overrides": []
},
"options": {
"calculate": false,
"yAxis": { "unit": "s" },
"color": { "mode": "scheme", "scheme": "Oranges" },
"cellGap": 1,
"showValue": "never"
}
},
{
"id": 7,
"title": "Poll Rate by Status",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 13 },
"datasource": "Prometheus",
"targets": [
{
"expr": "rate(mikrotik_poll_total[5m])",
"legendFormat": "{{status}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "ops",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 20,
"stacking": { "mode": "normal" }
}
},
"overrides": [
{
"matcher": { "id": "byName", "options": "success" },
"properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }]
},
{
"matcher": { "id": "byName", "options": "error" },
"properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }]
},
{
"matcher": { "id": "byName", "options": "skipped" },
"properties": [{ "id": "color", "value": { "fixedColor": "yellow", "mode": "fixed" } }]
}
]
},
"options": {
"tooltip": { "mode": "multi" },
"legend": { "displayMode": "list", "placement": "bottom" }
}
},
{
"id": 8,
"title": "NATS Publish Rate by Subject",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 13 },
"datasource": "Prometheus",
"targets": [
{
"expr": "sum(rate(mikrotik_nats_publish_total{status=\"success\"}[5m])) by (subject)",
"legendFormat": "{{subject}} (ok)",
"refId": "A"
},
{
"expr": "sum(rate(mikrotik_nats_publish_total{status=\"error\"}[5m])) by (subject)",
"legendFormat": "{{subject}} (err)",
"refId": "B"
}
],
"fieldConfig": {
"defaults": {
"unit": "ops",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 10
}
},
"overrides": []
},
"options": {
"tooltip": { "mode": "multi" },
"legend": { "displayMode": "table", "placement": "bottom", "calcs": ["mean"] }
}
},
{
"id": 9,
"title": "Redis Lock Operations/s",
"type": "timeseries",
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 21 },
"datasource": "Prometheus",
"targets": [
{
"expr": "rate(mikrotik_redis_lock_total[5m])",
"legendFormat": "{{status}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "ops",
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 10
}
},
"overrides": [
{
"matcher": { "id": "byName", "options": "obtained" },
"properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }]
},
{
"matcher": { "id": "byName", "options": "not_obtained" },
"properties": [{ "id": "color", "value": { "fixedColor": "yellow", "mode": "fixed" } }]
},
{
"matcher": { "id": "byName", "options": "error" },
"properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }]
}
]
},
"options": {
"tooltip": { "mode": "multi" },
"legend": { "displayMode": "list", "placement": "bottom" }
}
}
]
}

View File

@@ -0,0 +1,12 @@
apiVersion: 1
providers:
- name: 'TOD'
orgId: 1
folder: 'TOD'
type: file
disableDeletion: false
editable: true
updateIntervalSeconds: 30
options:
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: false

View File

@@ -0,0 +1,8 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false

View File

@@ -0,0 +1,18 @@
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'mikrotik-api'
metrics_path: /metrics
static_configs:
- targets: ['api:8000']
labels:
service: 'api'
- job_name: 'mikrotik-poller'
metrics_path: /metrics
static_configs:
- targets: ['poller:9091']
labels:
service: 'poller'

38
infrastructure/openbao/init.sh Executable file
View File

@@ -0,0 +1,38 @@
#!/bin/sh
# OpenBao Transit initialization script
# Runs after OpenBao starts in dev mode
set -e
export BAO_ADDR="http://127.0.0.1:8200"
export BAO_TOKEN="${BAO_DEV_ROOT_TOKEN_ID:-dev-openbao-token}"
# Wait for OpenBao to be ready
echo "Waiting for OpenBao to start..."
until bao status >/dev/null 2>&1; do
sleep 0.5
done
echo "OpenBao is ready"
# Enable Transit secrets engine (idempotent - ignores "already enabled" errors)
bao secrets enable transit 2>/dev/null || true
echo "Transit engine enabled"
# Create policy for the API backend (full Transit access)
bao policy write api-policy - <<'POLICY'
path "transit/*" {
capabilities = ["create", "read", "update", "delete", "list"]
}
POLICY
# Create policy for the Go poller (encrypt + decrypt only)
bao policy write poller-policy - <<'POLICY'
path "transit/decrypt/tenant_*" {
capabilities = ["update"]
}
path "transit/encrypt/tenant_*" {
capabilities = ["update"]
}
POLICY
echo "OpenBao Transit initialization complete"