feat(openclaw): add shopping, supermarket deals, geizhals, and ebay MCP tools

This commit is contained in:
Moritz Graf
2026-09-10 21:32:22 +02:00
parent 2f2f597a67
commit 1e81cdbead
4754 changed files with 1298747 additions and 896 deletions
+65
View File
@@ -160,6 +160,27 @@ velero backup get
```
## authentik (auth)
Authentik is the central SSO and identity provider deployed at [auth.haumdaucher.de](https://auth.haumdaucher.de).
See full documentation in [k8s/auth/README.md](./auth/README.md) and [k8s/auth/MACHINE_AUTH.md](./auth/MACHINE_AUTH.md).
```sh
# 1. Create namespace & CNPG PostgreSQL cluster
kubectl create namespace auth
kubectl apply -f auth/authentik-db.yaml
# 2. Deploy Authentik via Helm
helm repo add authentik https://charts.goauthentik.io
helm repo update
helm upgrade --install authentik authentik/authentik \
--namespace auth \
-f auth/authentik.secret.values.yaml
# 3. Deploy Traefik ForwardAuth Middleware
kubectl apply -f auth/traefik-forwardauth-middleware.yaml
```
## coder
Coder is a code server see [https://coder.com/docs/v2/latest/install/kubernetes](https://coder.com/docs/v2/latest/install/kubernetes).
@@ -841,3 +862,47 @@ helm upgrade --install zigbee2mqtt zigbee2mqtt/zigbee2mqtt --create-namespace -n
```
To modify configurations (like `devices`, `throttle`, or `mqtt`), edit `zigbee/zigbee2mqtt.secret.values.yaml` and re-run the `helm upgrade` command. The chart automatically handles merging these values into the persistent volume on startup.
## Grabengasse 7 Website (`grabengasse7.de`)
Grabengasse 7 home website and backend service deployed in the `grabengasse` namespace. Exposes outdoor weather metrics from the Shelly WS90 station with interactive 24h charts.
### Build and Push Container Images
```bash
# Login to local registry (if not already logged in)
docker login registry.haumdaucher.de -u moritz -p "PPf10musSh!!"
# Build and push backend image
cd website/grabengasse_backend
make all
# Build and push frontend image
cd ../grabengasse_frontend
make all
```
### Deploy to Kubernetes
```bash
# Create namespace and registry secret
kubectl apply -f k8s/grabengasse/namespace.yaml
kubectl apply -f k8s/grabengasse/registry.secret.yaml
# Deploy backend, frontend, and ingress
kubectl apply -f k8s/grabengasse/backend.yaml
kubectl apply -f k8s/grabengasse/frontend.yaml
kubectl apply -f k8s/grabengasse/ingress.yaml
```
### Verification
```bash
# Verify pod statuses
kubectl get pods -n grabengasse
# Check backend health
curl -k https://grabengasse7.de/api/v1/healthz
curl -k https://grabengasse7.de/api/v1/weather/current
```
+65
View File
@@ -0,0 +1,65 @@
# Machine & AI Assistant Authentication Guide
This guide describes how to configure programmatic, machine-to-machine, and AI assistant authentication across services protected by **Authentik** and **Traefik ForwardAuth**.
---
## 🎯 Use Case
When an AI assistant (or automation script, CLI tool, or cron job) needs to:
* Inspect Zigbee2MQTT logs or device states via `https://zigbee2mqtt.moritzgraf.de`
* Scrape or query metrics from `https://prometheus.haumdaucher.de`
* Trigger or query APIs protected behind the Traefik ForwardAuth gateway
Instead of interactive browser logins or hardcoding static basic auth credentials into individual ingress configs, Authentik supports **Service Accounts & API Tokens**.
---
## ⚙️ Setup Instructions
### 1. Create a Service Account in Authentik
1. Navigate to **Directory > Users** in Authentik (`https://auth.haumdaucher.de`).
2. Click **Create Service Account**:
* **Username**: `ai-assistant` (or `mopbot`, `cli-agent`)
* **Create API Token**: Check this box.
* **Token Expiration**: Set to never expire (or desired TTL).
3. Copy the generated **Token Secret** (e.g. `ak_tok_xxxx...`).
### 2. Grant Access Permissions
1. Go to **Directory > Groups**.
2. Add the `ai-assistant` service account to the appropriate group (e.g. `infra-admins` or `zigbee-readers`).
3. Ensure the Application/Proxy Provider policy allows this group.
---
## 💻 Making Authenticated API Calls
### HTTP Bearer Token (Recommended)
Pass the API token in the `Authorization` header:
```bash
curl -H "Authorization: Bearer <your-authentik-token>" \
https://zigbee2mqtt.moritzgraf.de/api/bridge/info
```
When Traefik ForwardAuth queries Authentik with this header, Authentik detects the Bearer token, authenticates the `ai-assistant` service account, checks permissions, and responds with HTTP `200 OK`. Traefik then proxies the request directly to the backend.
### Python / Scripting Example
```python
import requests
AUTH_TOKEN = "your-authentik-token"
headers = {
"Authorization": f"Bearer {AUTH_TOKEN}",
"Accept": "application/json"
}
response = requests.get("https://zigbee2mqtt.moritzgraf.de/api/bridge/devices", headers=headers)
print(response.json())
```
---
## 🔄 Rotating & Revoking Tokens
* To rotate a token, navigate to **Directory > Tokens and App passwords**.
* You can view active tokens, inspect last-used timestamps, and revoke compromised tokens instantly with one click without restarting any Kubernetes pods or modifying ingress secrets.
+188
View File
@@ -0,0 +1,188 @@
# Authentik Identity & Access Management (SSO)
Centralized authentication and identity provider for the `haumdaucher` Kubernetes cluster.
* **Domain**: `https://auth.haumdaucher.de`
* **Helm Chart**: `authentik/authentik` (Version 2026.8.0)
* **Database**: CloudNative-PG (`cnpg`) PostgreSQL cluster (`authentik-db`)
* **Ingress**: Traefik with Let's Encrypt TLS certificate (`auth-haumdaucher-de`)
* **Capabilities**: OIDC/OAuth2 Provider, Traefik ForwardAuth, 2FA (WebAuthn/Passkey, TOTP), User Invitations, Machine/AI Service Accounts.
---
## 🏗️ Architecture Layout
```mermaid
graph TD
Client[Browser / User] -->|HTTPS| Traefik[Traefik Ingress]
AI[AI Assistant / CLI Script] -->|HTTPS + Bearer Token| Traefik
subgraph "ForwardAuth Ingresses"
Traefik -->|ForwardAuth Check| ForwardAuthMW[Traefik ForwardAuth Middleware]
ForwardAuthMW -->|Verify Session or Token| AuthServer[Authentik Server]
Traefik -->|Allow| Z2M[Zigbee2MQTT]
Traefik -->|Allow| Prom[Prometheus & Alertmanager]
Traefik -->|Allow| PMA[phpMyAdmin]
Traefik -->|Allow| OC[OpenClaw]
Traefik -->|Allow| N8N[n8n Web UI]
end
subgraph "Native OIDC Workloads"
Grafana[Grafana] -->|OIDC Auth Code Flow| AuthServer
Coder[Coder v2] -->|OIDC Auth| AuthServer
Gitea[Gitea] -->|OIDC Auth| AuthServer
Nextcloud[Nextcloud user_oidc] -->|OIDC Auth| AuthServer
MinIO[MinIO Console] -->|OIDC Auth| AuthServer
end
subgraph "auth Namespace"
AuthServer --> DB[(CNPG Postgres: authentik-db)]
AuthWorker[Authentik Worker] --> DB
end
```
---
## 🚀 Deployment Instructions
### 1. Create Namespace & Provision CNPG Database
```bash
# Create auth namespace
kubectl create namespace auth
# Apply CNPG PostgreSQL cluster
kubectl apply -f k8s/auth/authentik-db.yaml
# Verify PostgreSQL cluster is ready (Status: Cluster in healthy state)
kubectl get cluster -n auth authentik-db
```
### 2. Deploy Authentik via Helm
```bash
# Add Authentik repository
helm repo add authentik https://charts.goauthentik.io
helm repo update
# Install or upgrade Authentik release
helm upgrade --install authentik authentik/authentik \
--namespace auth \
-f k8s/auth/authentik.secret.values.yaml
```
### 3. Deploy Traefik ForwardAuth Middleware
```bash
kubectl apply -f k8s/auth/traefik-forwardauth-middleware.yaml
```
---
## 🔑 Initial Setup & Admin Onboarding
1. Open your browser and navigate to:
```
https://auth.haumdaucher.de/if/flow/initial-setup/
```
2. Set a secure password for the default `akadmin` account.
3. Configure your admin user profile and optionally enroll a WebAuthn / Passkey / TOTP 2FA device.
---
## 👥 User Management & Invitations
### Creating Users & Sending Invites
1. Navigate to **Directory > Users** in the Authentik Admin interface.
2. You can create users manually or upload/provision users in batches.
3. To send enrollment links, go to **Directory > Invitations**:
* Click **Create**.
* Set the target email and assign user groups (e.g. `family`, `friends`, `admins`).
* Copy the generated invitation URL or send it to the user.
### Group Permissions
* `authentik Admins`: Full administrative control over Authentik.
* `infra-admins`: Access to infrastructure tools (Prometheus, Alertmanager, phpMyAdmin, Zigbee2MQTT).
* `users`: Standard user group with access to shared web services (Grafana dashboards, Coder, Nextcloud, Gitea).
👉 **For the complete guide on Terraform user management, bulk Excel/CSV imports, custom email templates, and RBAC/PBAC access limiting, see [USER_MANAGEMENT.md](./USER_MANAGEMENT.md).**
---
## 🤖 Machine & AI Agent Access (Programmatic Authentication)
For automated tools, scripts, or AI assistants that need to inspect protected applications (e.g. querying Zigbee2MQTT API/logs or Prometheus metrics):
1. **Create a Service Account**:
* In Authentik Admin, navigate to **Directory > Users**.
* Click **Create Service Account** (e.g. `ai-agent` or `automation-bot`).
* Create a permanent API token for the service account.
2. **Accessing Protected Endpoints**:
* Pass the token in the HTTP `Authorization` header:
```bash
curl -H "Authorization: Bearer <your-authentik-token>" https://zigbee2mqtt.moritzgraf.de/api/...
```
* Authentik's ForwardAuth middleware validates the Bearer token and allows the request without redirecting to a web login page.
See [MACHINE_AUTH.md](./MACHINE_AUTH.md) for full details.
---
## 🔒 Protecting Applications (Rollout Guide)
### Pattern 1: Traefik ForwardAuth (Proxy Protection)
Use this for applications without native OIDC (e.g. Zigbee2MQTT, Prometheus, phpMyAdmin, OpenClaw).
1. In Authentik Admin, go to **Applications > Providers**:
* Create a **Proxy Provider**.
* Name: `<App Name> Proxy` (e.g. `Zigbee2MQTT Proxy`).
* Mode: `Forward auth (single application)`.
* External Host: `https://<app-domain>` (e.g. `https://zigbee2mqtt.moritzgraf.de`).
2. Go to **Applications > Applications**:
* Create Application, link the Provider, and set access policies/groups.
3. Add the middleware annotation to the application's Kubernetes Ingress:
```yaml
annotations:
traefik.ingress.kubernetes.io/router.middlewares: auth-authentik-forwardauth@kubernetescrd
```
### Pattern 2: Native OIDC SSO
Use this for applications supporting OpenID Connect (Grafana, Coder, Gitea, Nextcloud).
1. In Authentik Admin, create an **OAuth2/OpenID Provider**:
* Name: `<App Name> OIDC`
* Client Type: `Confidential`
* Redirect URI: e.g. `https://grafana.haumdaucher.de/login/generic_oauth`
2. Create an **Application** in Authentik and assign it to the Provider.
3. Configure the client credentials (`client_id` and `client_secret`) in the application's Helm values / ConfigMap.
---
## 🎨 Branding & Haumdaucher UI Theme
The Authentik login screen is customized with a **Haumdaucher** glassmorphism theme matching `haumdaucher.de`.
* **Styling Highlights**:
* **Glassmorphic Card**: Frosted glass panel (`backdrop-filter: blur(20px)`), smooth rounded corners (`24px`), and elegant ambient background aura.
* **Typography**: Clean, modern typography using Google Font **Outfit**.
* **Logo & Entrance Animation**: Dynamic 3D scale-and-fade entry animation for the Haumdaucher crest logo (`https://haumdaucher.de/images/logo_classic.png`).
* **Dual Light & Dark Theme**: Automatic adaptation with dark mode styling featuring obsidian surfaces, gold accents (`#d4af37`), and glowing tactile buttons.
* **Branded Footer**: "HAUMDAUCHER Wurst und Spezialitäten n.e.V." subtle watermark.
### Managing Theme via Terraform
Branding is provisioned via the `authentik_brand` resource in `terraform/authentik.tf` using the stylesheet `terraform/haumdaucher-theme.css`:
```bash
cd terraform
terraform apply -target=authentik_brand.haumdaucher
```
---
## 💾 Backup & Disaster Recovery
* **Database**: Managed by CloudNative-PG on `openebs-hostpath` persistent storage.
* **Velero Integration**: The `auth` namespace is backed up during daily/weekly Velero schedules:
```bash
velero backup create auth-backup-$(date +%Y%m%d) --include-namespaces auth --wait
```
+629
View File
@@ -0,0 +1,629 @@
# Authentik User Management: Complete Roundup & Operations Guide
This guide covers everything you need to know about managing users in Authentik for the **Haumdaucher** infrastructure (`https://auth.haumdaucher.de`): from manual creation and invitation workflows to Terraform IaC, custom email branding, fine-grained access control, handling custom attributes (e.g., Vereins-Mitgliedsnummer, second email), and bulk-importing users from an Excel / CSV spreadsheet.
---
## 📑 Table of Contents
1. [Executive Summary: Quick Answers](#-executive-summary-quick-answers)
2. [Method 1: Manual Web UI & Invitation Links](#-method-1-manual-web-ui--invitation-links)
3. [Method 2: User Provisioning via Terraform](#-method-2-user-provisioning-via-terraform)
4. [Method 3: Bulk Import from Excel / CSV (Python Script)](#-method-3-bulk-import-from-excel--csv-python-script)
5. [Customizing Registration & Onboarding Emails](#-customizing-registration--onboarding-emails)
6. [Access Control & Authorization Policies (Limiting App Access)](#-access-control--authorization-policies-limiting-app-access)
7. [Custom Attributes & OIDC Claim Mappings](#-custom-attributes--oidc-claim-mappings)
8. [Production Checklist & Best Practices](#-production-checklist--best-practices)
---
## ⚡ Executive Summary: Quick Answers
| Question | Answer & Key Mechanism |
| :--- | :--- |
| **How to add new users?** | **4 Ways**: (1) Admin Web UI (`Directory > Users`), (2) **Invitations flow** (`Directory > Invitations` - recommended for self-service), (3) **Terraform** (`authentik_user` resource), or (4) **REST API / Python script** for bulk imports. |
| **Can this be done in Terraform?** | **Yes.** Use `resource "authentik_user"` in `terraform/authentik.tf`. Supports custom attributes via `jsonencode()`, group assignments, and active status. |
| **Can I change the email sent to the user?** | **Yes.** Configured in **Flows & Stages > Stages > Email Stage**. You can customize the subject line, token expiration, and HTML template (written in Django template syntax with access to user fields and custom attributes). |
| **Can I limit what a user has access to?** | **Yes.** Through **Policy Bindings** attached to Applications: Group Membership Policies (e.g., `infra-admins` vs `users`), IP/Geo Policies, or Python Expression Policies checking custom attributes (e.g., only active club members). |
| **How to handle Excel tables with custom attributes?** | Authentik stores arbitrary key-value pairs in `user.attributes` (JSON). You can import an Excel/CSV file with a Python script or Terraform `csvdecode()`, map fields like `haumdaucher_mitgliedsnummer` or `second_email`, and expose them to downstream apps (Nextcloud, Grafana, Gitea) via OIDC Scope Mappings. |
---
## 👤 Method 1: Manual Web UI & Invitation Links
Authentik offers two UI methods to onboard users:
### Option A: Direct User Creation (Admin sets password or triggers reset)
1. Go to `https://auth.haumdaucher.de` and log in with your admin account.
2. Navigate to **Directory > Users** and click **Create**.
3. Fill in:
* **Username**: `max.mustermann`
* **Name**: `Max Mustermann`
* **Email**: `max@example.com`
* **Type**: `Internal`
* **Groups**: Select groups (e.g., `users`, `verein-mitglieder`).
* **Attributes (YAML/JSON)**:
```json
{
"second_email": "max.privat@gmail.com",
"haumdaucher_mitgliedsnummer": "HM-2024-042",
"phone": "+49 170 1234567"
}
```
4. Click **Create**.
5. To set credentials: Click on the user -> **Actions** -> **Set Password** (or **Explicitly trigger password reset flow** so the user receives a link via email).
---
---
## ⚠️ Troubleshooting: "Flow does not apply to current user"
If you encounter this screen when opening an invite link:
> **"Flow does not apply to current user."**
### Why this happens:
1. **Wrong Flow Selected (`default-source-enrollment`)**:
In your URL (`/if/flow/default-source-enrollment/?itoken=...`), the flow used was `default-source-enrollment`. That flow is strictly designed for external identity providers (OAuth/SAML like Google/GitHub) and binds a policy that checks for an active external source in the context. When accessed directly with an `?itoken=...`, Authentik evaluates this policy, fails, and displays this error.
2. **Missing Invitation Stage**:
Standard enrollment flows reject direct token access unless an **`Invitation Stage`** is bound to the beginning of the flow to validate `?itoken=...`.
3. **Active Admin Session**:
If an administrator is currently logged in to the browser and clicks the enrollment link, flows with an "unauthenticated-only" policy will reject the session. Always test invitation URLs in an **Incognito / Private window**.
---
### Solution: The Haumdaucher Custom Invitation Flow (`haumdaucher-invitation-flow`)
We provision a dedicated, streamlined flow in `terraform/authentik.tf`:
```mermaid
graph LR
Link[Invite Link with ?itoken=...] --> Stage1[1. Invitation Stage<br/>Validates Token]
Stage1 --> Stage2[2. Prompt Stage<br/>User inputs Username, Name, Password]
Stage2 --> Stage3[3. User Write Stage<br/>Writes to DB & assigns Groups]
Stage3 --> Stage4[4. User Login Stage<br/>Logs user into session]
Stage4 --> Stage5[5. Redirect Stage<br/>Redirects to haumdaucher.de]
```
#### Flow Behavior (Tailored to your setup):
* **User Inputs**: Prompts the user to choose their preferred **Username**, **Full Name**, and **Password** (with confirmation).
* **Email & Custom Attributes**: Pre-filled from the admin invitation (e.g. `haumdaucher_mitgliedsnummer`, `second_email`).
* **MFA / 2FA**: Optional. The user registers immediately with their password and can enroll Passkeys/TOTP anytime in their profile settings.
* **Automatic Redirect**: Upon completing registration, the user is automatically logged in and redirected to **`https://haumdaucher.de`**.
---
### How to Create an Invitation using the Custom Flow
1. Go to `https://auth.haumdaucher.de` > **Directory > Invitations**.
2. Click **Create** (or **New Invitation**).
3. Set the fields:
* **Name**: `Invite - Max Mustermann`
* **Expires**: Set expiration (e.g., 7 days).
* **Flow**: Select **`Haumdaucher Invitation Flow`** (`haumdaucher-invitation-flow`).
* **Single use**: Checked (`true`).
* **Fixed Data / Attributes (JSON)**:
```json
{
"email": "max@example.com",
"haumdaucher_mitgliedsnummer": "HM-2024-042",
"second_email": "max.privat@gmail.com"
}
```
4. Click **Create**.
5. Copy the generated URL:
```
https://auth.haumdaucher.de/if/flow/haumdaucher-invitation-flow/?itoken=<generated-token>
```
6. Send the link to the user. When they open it, they will see the branded Haumdaucher registration form asking for their Username, Name, and Password!
---
## 🏗️ Method 2: User Provisioning via Terraform
You can manage users, groups, and permissions directly in `terraform/authentik.tf`.
### 1. Single User Definition
```hcl
# Group for club members
resource "authentik_group" "verein_mitglieder" {
name = "verein-mitglieder"
is_superuser = false
}
# User with custom attributes
resource "authentik_user" "max_mustermann" {
username = "max.mustermann"
name = "Max Mustermann"
email = "max@example.com"
type = "internal"
is_active = true
groups = [
authentik_group.users.id,
authentik_group.verein_mitglieder.id
]
attributes = jsonencode({
"second_email" = "max.privat@gmail.com",
"haumdaucher_mitgliedsnummer" = "HM-2024-042",
"eintrittsdatum" = "2024-01-15",
"status" = "aktiv"
})
}
```
### 2. Batch Provisioning from CSV in Terraform
If you maintain users in a CSV file (`terraform/users.csv`):
```csv
username,name,email,second_email,mitgliedsnummer,is_admin
max.mustermann,Max Mustermann,max@example.com,max.privat@gmail.com,HM-2024-042,false
anna.schmidt,Anna Schmidt,anna@example.com,anna.work@company.de,HM-2024-043,true
```
In `terraform/authentik.tf`:
```hcl
locals {
csv_users = csvdecode(file("${path.module}/users.csv"))
}
resource "authentik_user" "csv_members" {
for_each = { for u in local.csv_users : u.username => u }
username = each.value.username
name = each.value.name
email = each.value.email
type = "internal"
is_active = true
groups = compact([
authentik_group.users.id,
authentik_group.verein_mitglieder.id,
each.value.is_admin == "true" ? authentik_group.infra_admins.id : null
])
attributes = jsonencode({
"second_email" = each.value.second_email,
"haumdaucher_mitgliedsnummer" = each.value.mitgliedsnummer
})
}
```
### ⚖️ Trade-offs: When to use Terraform for Users
* **Pros**: Version-controlled in Git, declarative, reproducible, ideal for admins and service accounts (`ai_assistant`).
* **Cons / Considerations**: Terraform cannot trigger interactive password onboarding emails out of the box; passwords must be set via recovery flow or first login reset.
---
## 📊 Method 3: Bulk Import from Excel / CSV (Python Script)
If you have an Excel file (`mitglieder.xlsx`) or CSV, using Authentik's REST API is the cleanest approach because it can **create users, attach custom attributes, and automatically email them an invitation link to set their own password**.
### Ready-to-Run Python Import Script
Save this script (e.g. `k8s/auth/scripts/import_users_excel.py`):
```python
#!/usr/bin/env python3
"""
Bulk User Importer for Authentik (auth.haumdaucher.de)
Reads an Excel (.xlsx) or CSV file and creates users with custom attributes and invitation links.
"""
import sys
import os
import requests
import pandas as pd
# Configuration
AUTHENTIK_URL = os.getenv("AUTHENTIK_URL", "https://auth.haumdaucher.de")
AUTHENTIK_TOKEN = os.getenv("AUTHENTIK_TOKEN") # API Token (e.g. ai_assistant_token)
if not AUTHENTIK_TOKEN:
print("❌ Error: AUTHENTIK_TOKEN environment variable is required.")
sys.exit(1)
HEADERS = {
"Authorization": f"Bearer {AUTHENTIK_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json"
}
def get_or_create_group(group_name: str) -> str:
"""Fetch group ID or create if it doesn't exist."""
res = requests.get(f"{AUTHENTIK_URL}/api/v3/core/groups/?name={group_name}", headers=HEADERS)
res.raise_for_status()
results = res.json().get("results", [])
if results:
return results[0]["pk"]
# Create group
create_res = requests.post(f"{AUTHENTIK_URL}/api/v3/core/groups/", headers=HEADERS, json={"name": group_name})
create_res.raise_for_status()
return create_res.json()["pk"]
def import_users_from_file(file_path: str, send_invitation: bool = True):
# Read Excel or CSV
if file_path.endswith((".xlsx", ".xls")):
df = pd.read_excel(file_path)
else:
df = pd.read_csv(file_path)
# Standardize column names (lowercase & stripped)
df.columns = [c.strip().lower() for c in df.columns]
print(f"📋 Found {len(df)} rows to process.")
users_group_pk = get_or_create_group("users")
verein_group_pk = get_or_create_group("verein-mitglieder")
for idx, row in df.iterrows():
name = str(row.get("name", "")).strip()
email = str(row.get("email", "")).strip()
username = str(row.get("username", "")).strip() or email.split("@")[0].lower()
second_email = str(row.get("second_email", row.get("zweite_email", ""))).strip()
mitgliedsnummer = str(row.get("mitgliedsnummer", row.get("verein_mitgliedsnummer", ""))).strip()
if not email or email == "nan":
print(f"⚠️ Skipping row {idx+1}: missing email")
continue
# Prepare user payload with custom attributes
user_payload = {
"username": username,
"name": name if name != "nan" else username,
"email": email,
"type": "internal",
"is_active": True,
"groups": [users_group_pk, verein_group_pk],
"attributes": {
"second_email": second_email if second_email != "nan" else "",
"haumdaucher_mitgliedsnummer": mitgliedsnummer if mitgliedsnummer != "nan" else "",
"source": "excel_import"
}
}
# 1. Create or Update User in Authentik
res = requests.get(f"{AUTHENTIK_URL}/api/v3/core/users/?username={username}", headers=HEADERS)
res.raise_for_status()
existing = res.json().get("results", [])
if existing:
user_pk = existing[0]["pk"]
patch_res = requests.patch(f"{AUTHENTIK_URL}/api/v3/core/users/{user_pk}/", headers=HEADERS, json=user_payload)
patch_res.raise_for_status()
print(f"🔄 Updated existing user: {username} ({email})")
else:
create_res = requests.post(f"{AUTHENTIK_URL}/api/v3/core/users/", headers=HEADERS, json=user_payload)
if not create_res.ok:
print(f"❌ Failed to create {username}: {create_res.text}")
continue
user_pk = create_res.json()["pk"]
print(f"✅ Created user: {username} ({email}) [ID: {user_pk}]")
# 2. Generate Invitation / Password Reset Link
if send_invitation:
invitation_payload = {
"name": f"Onboarding {username}",
"single_use": True,
"fixed_data": {
"email": email,
"username": username
}
}
inv_res = requests.post(f"{AUTHENTIK_URL}/api/v3/stages/invitation/invitations/", headers=HEADERS, json=invitation_payload)
if inv_res.ok:
inv_key = inv_res.json()["pk"]
link = f"{AUTHENTIK_URL}/if/flow/default-enrollment-flow/?itoken={inv_key}"
print(f" ✉️ Invitation Link: {link}")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python3 import_users_excel.py <path-to-excel-or-csv>")
sys.exit(1)
import_users_from_file(sys.argv[1])
```
---
## ✉️ Customizing Registration & Onboarding Emails
When an admin creates a user or triggers an enrollment/password recovery, Authentik sends transactional emails. You can customize their sender address, subject line, and full HTML appearance.
### 1. SMTP & Sender Settings
Authentik sends emails using the SMTP configuration defined in `k8s/auth/authentik.secret.values.yaml` or global environment variables:
* `AUTHENTIK_EMAIL__HOST`: `smtp.gmail.com` (or Google Workspace / OVH relay)
* `AUTHENTIK_EMAIL__PORT`: `587`
* `AUTHENTIK_EMAIL__USE_TLS`: `true`
* `AUTHENTIK_EMAIL__USERNAME`: `service@haumdaucher.de`
* `AUTHENTIK_EMAIL__PASSWORD`: `<app-password>`
* `AUTHENTIK_EMAIL__FROM`: `HAUMDAUCHER Identity <service@haumdaucher.de>`
---
### 2. Customizing Email Stages in Authentik
Authentik controls outgoing emails via **Email Stages**:
1. Open Admin UI: **Flows & Stages > Stages**.
2. Locate the relevant email stage:
* `default-password-reset-email` (for password recovery & manual user activation)
* `default-enrollment-email` / `invitation-email` (for new user invitations)
* `default-user-settings-password-reset-email`
3. Click **Edit** on the stage:
* **Subject**: `Willkommen bei Haumdaucher e.V. – Aktiviere deinen Zugang`
* **Token Expiry**: `Minutes=1440` (24 hours) or `Minutes=10080` (7 days)
* **Template**: Select standard template or your custom template path.
---
### 3. Customizing the HTML Email Template (Django Syntax)
Authentik email templates use Django template syntax. Available variables in email templates include:
* `{{ user.name }}` / `{{ user.username }}` / `{{ user.email }}`
* `{{ user.attributes.haumdaucher_mitgliedsnummer }}`
* `{{ url }}` (The activation / password reset link)
* `{{ token_expires }}` (Expiry timestamp)
* `{{ brand.branding_title }}` (`HAUMDAUCHER`)
#### Example Custom Haumdaucher Welcome Template
```html
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background-color: #0d0d12; color: #f0f0f5; margin: 0; padding: 40px; }
.card { background: #181824; border: 1px solid rgba(212, 175, 55, 0.3); border-radius: 16px; max-width: 540px; margin: 0 auto; padding: 32px; box-shadow: 0 12px 30px rgba(0,0,0,0.5); }
.header { text-align: center; margin-bottom: 24px; }
.logo { height: 60px; }
h1 { color: #d4af37; font-size: 22px; margin-top: 12px; }
p { color: #c0c0d0; line-height: 1.6; font-size: 15px; }
.btn { display: block; text-align: center; background: linear-gradient(135deg, #d4af37, #aa8520); color: #000; font-weight: bold; text-decoration: none; padding: 14px 24px; border-radius: 10px; margin: 28px 0; }
.meta { background: rgba(255,255,255,0.05); border-radius: 8px; padding: 12px 16px; font-size: 13px; color: #a0a0b0; }
.footer { text-align: center; font-size: 12px; color: #606070; margin-top: 30px; }
</style>
</head>
<body>
<div class="card">
<div class="header">
<img class="logo" src="https://haumdaucher.de/images/logo_classic.png" alt="Haumdaucher Logo" />
<h1>Willkommen beim Haumdaucher e.V.</h1>
</div>
<p>Hallo <strong>{{ user.name }}</strong>,</p>
<p>dein zentraler Zugang für die Haumdaucher Cloud & Services wurde eingerichtet.</p>
{% if user.attributes.haumdaucher_mitgliedsnummer %}
<div class="meta">
<strong>Mitgliedsnummer:</strong> {{ user.attributes.haumdaucher_mitgliedsnummer }}<br/>
<strong>Benutzername:</strong> {{ user.username }}
</div>
{% endif %}
<p>Klicke auf den Button unten, um dein Passwort festzulegen und deinen Zugang zu aktivieren:</p>
<a class="btn" href="{{ url }}">Zugang aktivieren & Passwort setzen</a>
<p style="font-size: 12px; color: #888;">Dieser Link ist 7 Tage lang gültig. Falls du die Registrierung nicht angefordert hast, kannst du diese E-Mail ignorieren.</p>
<div class="footer">
HAUMDAUCHER e.V. &bull; <a href="https://haumdaucher.de" style="color: #d4af37;">haumdaucher.de</a>
</div>
</div>
</body>
</html>
```
---
## 🔒 Access Control & Authorization Policies (Limiting App Access)
By default, an application without an attached policy allows **any authenticated user** to access it. Authentik provides fine-grained Access Control (RBAC & PBAC) to restrict access per application.
```mermaid
graph TD
subgraph "Authentik Directory Groups"
Admin["👑 <b>infra-admins</b><br/>(Moritz & Wife / Full Admin)"]
Verein["🍺 <b>verein-mitglieder</b><br/>(Club Members)"]
Normal["👤 <b>users</b><br/>(Normal Authenticated Users)"]
end
subgraph "Tier 1: Admin & Ops Tools (ForwardAuth / OIDC)"
Z2M["Zigbee2MQTT"]
Prom["Prometheus & Alertmanager"]
PMA["phpMyAdmin (Nextcloud & Gitea)"]
N8N["n8n Automation"]
MinIO["MinIO Console"]
Coder["Coder v2"]
OpenClaw["OpenClaw Engine"]
end
subgraph "Tier 2: Daily Shared Services (OIDC)"
HASS["Home Assistant"]
Grafana["Grafana Dashboards"]
Nextcloud["Nextcloud Cloud"]
Gitea["Gitea Git Platform"]
ClubWeb["haumdaucher.de"]
end
%% Admin Links (Full Superadmin access everywhere)
Admin ==>|Full Access| Z2M
Admin ==>|Full Access| OpenClaw
Admin ==>|Admin| HASS
Admin ==>|Admin| Grafana
Admin ==>|Admin| Nextcloud
Admin ==>|Admin| Gitea
Admin ==>|Admin| ClubWeb
%% Normal User Links (Restricted)
Normal -->|Viewer (Read-Only)| Grafana
Normal -->|Dashboard View| HASS
Normal -->|Standard User| Nextcloud
Normal -->|Developer| Gitea
%% Club Members Links
Verein -->|Member Portal| ClubWeb
Verein -->|Club Shared Folder| Nextcloud
Verein -->|Viewer (Read-Only)| Grafana
```
### Master Permission Matrix
| Application | Domain | Auth Type | `infra-admins` (Moritz & Wife) | `verein-mitglieder` (Club) | `users` (Normal Users) |
| :--- | :--- | :--- | :---: | :---: | :---: |
| **OpenClaw** | `openclaw.haumdaucher.de` | ForwardAuth | **Full Access** ✅ | ❌ *Denied* | ❌ *Denied* |
| **Zigbee2MQTT** | `zigbee2mqtt.moritzgraf.de` | ForwardAuth | **Full Access** ✅ | ❌ *Denied* | ❌ *Denied* |
| **Prometheus / Alertmanager** | `prometheus.haumdaucher.de` | ForwardAuth | **Full Access** ✅ | ❌ *Denied* | ❌ *Denied* |
| **phpMyAdmin (All DBs)** | `*.phpmyadmin.haumdaucher.de` | ForwardAuth | **Full Access** ✅ | ❌ *Denied* | ❌ *Denied* |
| **n8n** | `n8n.moritzgraf.de` | ForwardAuth | **Full Access** ✅ | ❌ *Denied* | ❌ *Denied* |
| **MinIO Console** | `minio-console.haumdaucher.de` | Native OIDC | **`consoleAdmin`** ✅ | ❌ *Denied* | ❌ *Denied* |
| **Coder v2** | `coder.moritzgraf.de` | Native OIDC | **`Owner / Admin`** ✅ | ❌ *Denied* | ❌ *Denied* |
| **Home Assistant** | `hass.moritzgraf.de` | Native OIDC | **`Administrator`** ✅ | ❌ *Denied* | **`Standard User`** |
| **Grafana** | `grafana.haumdaucher.de` | Native OIDC | **`Admin`** ✅ | **`Viewer` (Read-Only)** | **`Viewer` (Read-Only)** |
| **Nextcloud** | `cloud.haumdaucher.de` | Native OIDC | **`Admin`** ✅ | **`User` (Club Folder)** | **`User` (Standard)** |
| **Gitea** | `git.moritzgraf.de` | Native OIDC | **`Admin`** ✅ | ❌ / Member | **`Developer`** |
| **haumdaucher.de** | `haumdaucher.de` | ForwardAuth / OIDC | **`Admin`** ✅ | **`Member Access`** ✅ | ❌ *Denied* |
### Policy Bindings in Terraform
All policy bindings are enforced directly in `terraform/authentik.tf`:
```hcl
# 1. Admin-Only Tools (Restricted strictly to infra-admins)
resource "authentik_policy_binding" "openclaw_admin_only" {
target = authentik_application.openclaw.uuid
group = authentik_group.infra_admins.id
order = 0
}
resource "authentik_policy_binding" "zigbee2mqtt_admin_only" {
target = authentik_application.zigbee2mqtt.uuid
group = authentik_group.infra_admins.id
order = 0
}
# 2. Shared & Member Applications (Accessible to users group)
resource "authentik_policy_binding" "grafana_users" {
target = authentik_application.grafana.uuid
group = authentik_group.users.id
order = 0
}
resource "authentik_policy_binding" "nextcloud_users" {
target = authentik_application.nextcloud.uuid
group = authentik_group.users.id
order = 0
}
```
---
### 2. Attribute-Based Policy (Python Expression Policy)
You can write Python expressions that evaluate custom user attributes dynamically:
```hcl
resource "authentik_policy_expression" "require_active_membership" {
name = "require-active-haumdaucher-membership"
expression = <<EOF
# Must have a valid Mitgliedsnummer and active status
mitgliedsnummer = request.user.attributes.get('haumdaucher_mitgliedsnummer')
status = request.user.attributes.get('status', 'aktiv')
if not mitgliedsnummer or not mitgliedsnummer.startswith('HM-'):
ak_message("Keine gültige Haumdaucher Mitgliedsnummer hinterlegt.")
return False
if status != 'aktiv':
ak_message("Deine Mitgliedschaft ist derzeit nicht aktiv.")
return False
return True
EOF
}
# Attach policy to Nextcloud application
resource "authentik_policy_binding" "nextcloud_active_members" {
target = authentik_application.nextcloud.uuid
policy = authentik_policy_expression.require_active_membership.id
order = 1
}
```
---
## 🏷️ Custom Attributes & OIDC Claim Mappings
Authentik allows storing any arbitrary JSON object inside `user.attributes`.
### 1. Recommended Attribute Schema for Haumdaucher
```json
{
"haumdaucher_mitgliedsnummer": "HM-2024-042",
"second_email": "max.privat@gmail.com",
"eintrittsdatum": "2024-01-15",
"verein_rolle": "Vorstand / Kassenwart / Mitglied",
"phone": "+49 170 1234567",
"telegram_handle": "@max_mustermann"
}
```
---
### 2. Exposing Custom Attributes via OIDC Claims (Terraform)
To pass `haumdaucher_mitgliedsnummer` or `second_email` to downstream applications like **Nextcloud**, **Grafana**, or **Gitea**:
```hcl
# Define a custom Scope Mapping in Terraform
resource "authentik_property_mapping_provider_scope" "haumdaucher_profile" {
name = "Haumdaucher Extended Profile"
scope_name = "haumdaucher_profile"
description = "Provides Vereins-Mitgliedsnummer and secondary email to OIDC clients"
expression = <<EOF
return {
"haumdaucher_mitgliedsnummer": request.user.attributes.get("haumdaucher_mitgliedsnummer", ""),
"second_email": request.user.attributes.get("second_email", ""),
"verein_rolle": request.user.attributes.get("verein_rolle", "Mitglied")
}
EOF
}
# Add this mapping to the OAuth2 provider (e.g. Nextcloud or Grafana)
resource "authentik_provider_oauth2" "nextcloud" {
name = "Nextcloud"
client_id = "nextcloud"
client_secret = random_password.nextcloud_oauth_client_secret.result
client_type = "confidential"
authorization_flow = data.authentik_flow.default_authorization_flow.id
invalidation_flow = data.authentik_flow.default_invalidation_flow.id
signing_key = data.authentik_certificate_key_pair.generated.id
property_mappings = [
data.authentik_property_mapping_provider_scope.openid.id,
data.authentik_property_mapping_provider_scope.email.id,
data.authentik_property_mapping_provider_scope.profile.id,
authentik_property_mapping_provider_scope.haumdaucher_profile.id, # <-- Custom scope
]
}
```
---
## 🛡️ Production Checklist & Best Practices
When managing users in production, keep the following considerations in mind:
### 1. Passwordless & Multi-Factor Authentication (MFA / 2FA)
* Enforce **WebAuthn / Passkeys (FIDO2)** or **TOTP (Authenticator App)** for users in the `infra-admins` group.
* In Authentik, this is done by binding an Authenticator Validation Stage to the default authentication flow or creating an Admin Authorization Flow.
### 2. User Self-Service & Attribute Editing
* Authentik provides a self-service portal at `https://auth.haumdaucher.de/if/user/#/settings`.
* Users can change their password, register Passkeys, and edit user profile fields.
* **Security Rule**: Keep administrative attributes (like `haumdaucher_mitgliedsnummer`) read-only by not exposing them to the user edit flow, while allowing users to freely edit personal contact details like `second_email` or `phone`.
### 3. Offboarding & Member Deactivation
* If a member leaves the club:
1. Set `is_active = false` (or remove from `verein-mitglieder` group).
2. Authentik immediately terminates active sessions and revokes ForwardAuth access and OIDC refresh tokens.
3. No need to manually reset passwords on Nextcloud, Grafana, or Gitea—SSO handles deprovisioning instantly.
### 4. GDPR / DSGVO & Privacy
* Store only necessary data in user attributes.
* If a member requests account deletion, deleting the user in Authentik cascades identity termination across all connected services.
### 5. Backups & Audit Logs
* User database changes are stored in the PostgreSQL cluster (`authentik-db`) and backed up with daily Velero snapshot schedules.
* Authentik logs all login attempts, failed logins, and administrative modifications under **Events > Logs**.
+24
View File
@@ -0,0 +1,24 @@
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: authentik-db
namespace: auth
spec:
instances: 1
bootstrap:
initdb:
database: authentik
owner: authentik
postgresql:
parameters:
shared_buffers: "128MB"
max_connections: "100"
resources:
requests:
cpu: 100m
memory: 512Mi
limits:
memory: 2Gi
storage:
storageClass: openebs-hostpath
size: 5Gi
+62
View File
@@ -0,0 +1,62 @@
# --- Authentik Helm Configuration ---
# Uses CloudNative-PG cluster (authentik-db) for PostgreSQL
global:
env:
- name: AUTHENTIK_POSTGRESQL__HOST
value: "authentik-db-rw.auth.svc.cluster.local"
- name: AUTHENTIK_POSTGRESQL__NAME
value: "authentik"
- name: AUTHENTIK_POSTGRESQL__USER
value: "authentik"
- name: AUTHENTIK_POSTGRESQL__PASSWORD
valueFrom:
secretKeyRef:
name: authentik-db-app
key: password
authentik:
# Secret key for cookie signing and session encryption
secret_key: "gU2ohe8eighaiph4quaeCh1ieM3iechoh2ohNg5yahz6eephoh"
error_reporting:
enabled: false
log_level: info
server:
replicas: 1
resources:
requests:
cpu: 100m
memory: 384Mi
limits:
memory: 768Mi
ingress:
enabled: true
ingressClassName: "traefik"
annotations:
kubernetes.io/ingress.class: "traefik"
cert-manager.io/cluster-issuer: "letsencrypt-prod"
kubernetes.io/tls-acme: "true"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-body-size: "0"
hosts:
- auth.haumdaucher.de
tls:
- hosts:
- auth.haumdaucher.de
secretName: auth-haumdaucher-de
worker:
replicas: 1
resources:
requests:
cpu: 100m
memory: 384Mi
limits:
memory: 768Mi
# Disable bundled subchart databases (managed by CNPG)
postgresql:
enabled: false
-25
View File
@@ -1,25 +0,0 @@
# # status: removed again
# apiVersion: helm.fluxcd.io/v1
# kind: HelmRelease
# metadata:
# name: helm-phpldapadmin
# namespace: flux
# spec:
# releaseName: phpldapadmin
# targetNamespace: auth
# chart:
# repository: https://cetic.github.io/helm-charts
# version: 0.1.3
# name: phpldapadmin
# values:
# service:
# type: ClusterIP
# ingress:
# enabled: true
# hosts:
# - "ldap.haumdaucher.de"
# annotations:
# cert-manager.io/cluster-issuer: "letsencrypt-prod"
# tls:
# - ldap-haumdaucher-de
@@ -0,0 +1,26 @@
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: authentik-forwardauth
namespace: auth
spec:
forwardAuth:
address: http://authentik-server.auth.svc.cluster.local/outpost.goauthentik.io/auth/traefik
trustForwardHeader: true
authRequestHeaders:
- Authorization
- Cookie
- Accept
authResponseHeaders:
- X-authentik-username
- X-authentik-groups
- X-authentik-email
- X-authentik-name
- X-authentik-uid
- X-authentik-jwt
- X-authentik-meta-jwks
- X-authentik-meta-outpost
- X-authentik-meta-provider
- X-authentik-meta-app
- X-authentik-meta-version
- authorization
Binary file not shown.
+71 -10
View File
@@ -1,13 +1,74 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: gitea-db-adminer
namespace: development
spec:
replicas: 1
selector:
matchLabels:
app: gitea-db-adminer
template:
metadata:
labels:
app: gitea-db-adminer
spec:
containers:
- name: adminer
image: adminer:latest
ports:
- containerPort: 8080
name: http
env:
- name: ADMINER_DEFAULT_SERVER
value: "gitea-postgresql"
- name: ADMINER_DESIGN
value: "dracula"
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 128Mi
---
db:
host: gitea-mariadb
ingress:
enabled: true
hosts:
- path: "/"
tls: true
name: "gitea.phpmyadmin.haumdaucher.de"
tlsSecret: "gitea-phpmyadmin-haumdaucher-de"
apiVersion: v1
kind: Service
metadata:
name: gitea-db-adminer
namespace: development
spec:
type: ClusterIP
ports:
- port: 80
targetPort: 8080
name: http
selector:
app: gitea-db-adminer
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: gitea-phpmyadmin
namespace: development
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/proxy-body-size: "0"
kubernetes.io/ingress.class: "traefik"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
traefik.ingress.kubernetes.io/router.middlewares: auth-authentik-forwardauth@kubernetescrd
spec:
ingressClassName: traefik
tls:
- hosts:
- gitea.phpmyadmin.haumdaucher.de
secretName: gitea-phpmyadmin-haumdaucher-de
rules:
- host: gitea.phpmyadmin.haumdaucher.de
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: gitea-db-adminer
port:
number: 80
+65
View File
@@ -0,0 +1,65 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: grabengasse-backend
namespace: grabengasse
labels:
app: grabengasse-backend
spec:
replicas: 1
selector:
matchLabels:
app: grabengasse-backend
template:
metadata:
labels:
app: grabengasse-backend
spec:
imagePullSecrets:
- name: registry-haumdaucher-de
containers:
- name: backend
image: registry.haumdaucher.de/website/grabengasse_backend:latest
imagePullPolicy: Always
ports:
- containerPort: 8000
name: http
env:
- name: PROMETHEUS_URL
value: "http://prometheus-operator-kube-p-prometheus.monitoring.svc.cluster.local:9090"
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 256Mi
readinessProbe:
httpGet:
path: /healthz
port: 8000
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz
port: 8000
initialDelaySeconds: 10
periodSeconds: 15
---
apiVersion: v1
kind: Service
metadata:
name: grabengasse-backend
namespace: grabengasse
labels:
app: grabengasse-backend
spec:
type: ClusterIP
ports:
- port: 8000
targetPort: 8000
name: http
selector:
app: grabengasse-backend
+62
View File
@@ -0,0 +1,62 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: grabengasse-frontend
namespace: grabengasse
labels:
app: grabengasse-frontend
spec:
replicas: 1
selector:
matchLabels:
app: grabengasse-frontend
template:
metadata:
labels:
app: grabengasse-frontend
spec:
imagePullSecrets:
- name: registry-haumdaucher-de
containers:
- name: frontend
image: registry.haumdaucher.de/website/grabengasse_frontend:latest
imagePullPolicy: Always
ports:
- containerPort: 80
name: http
resources:
requests:
cpu: 25m
memory: 32Mi
limits:
cpu: 100m
memory: 128Mi
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 10
periodSeconds: 15
---
apiVersion: v1
kind: Service
metadata:
name: grabengasse-frontend
namespace: grabengasse
labels:
app: grabengasse-frontend
spec:
type: ClusterIP
ports:
- port: 80
targetPort: 80
name: http
selector:
app: grabengasse-frontend
+35
View File
@@ -0,0 +1,35 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: grabengasse7
namespace: grabengasse
annotations:
kubernetes.io/ingress.class: "traefik"
cert-manager.io/cluster-issuer: "letsencrypt-prod"
kubernetes.io/tls-acme: "true"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
spec:
ingressClassName: traefik
tls:
- hosts:
- grabengasse7.de
secretName: grabengasse7-de
rules:
- host: grabengasse7.de
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: grabengasse-backend
port:
number: 8000
- path: /
pathType: Prefix
backend:
service:
name: grabengasse-frontend
port:
number: 80
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v1
kind: Namespace
metadata:
name: grabengasse
labels:
name: grabengasse
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -15,7 +15,7 @@ data:
"tags": ["home", "weather", "outdoor", "prometheus"],
"timezone": "browser",
"schemaVersion": 39,
"version": 3,
"version": 4,
"refresh": "30s",
"editable": false,
"graphTooltip": 2,
@@ -29,7 +29,7 @@ data:
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [
{
"expr": "homeassistant_sensor_unit_lqi{entity=\"sensor.shelly_ws90_linkquality\"}",
"expr": "homeassistant_sensor_unit_lqi{entity=\"sensor.0xfc4d6afffe246794_linkquality\"}",
"refId": "A"
}
],
@@ -65,7 +65,7 @@ data:
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [
{
"expr": "homeassistant_sensor_battery_percent{entity=\"sensor.shelly_ws90_batterie\"}",
"expr": "homeassistant_sensor_battery_percent{entity=\"sensor.shelly_ws90_battery\"}",
"refId": "A"
}
],
@@ -100,7 +100,7 @@ data:
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [
{
"expr": "homeassistant_sensor_atmospheric_pressure_hpa{entity=\"sensor.shelly_ws90_atmospharischer_druck\"}",
"expr": "homeassistant_sensor_atmospheric_pressure_hpa{entity=\"sensor.shelly_ws90_pressure\"}",
"refId": "A"
}
],
@@ -166,7 +166,7 @@ data:
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [
{
"expr": "homeassistant_sensor_temperature_celsius{entity=\"sensor.shelly_ws90_temperatur\"}",
"expr": "homeassistant_sensor_temperature_celsius{entity=\"sensor.shelly_ws90_temperature\"}",
"legendFormat": "Temperature",
"refId": "A"
},
@@ -191,7 +191,7 @@ data:
"refId": "E"
},
{
"expr": "homeassistant_sensor_humidity_percent{entity=\"sensor.shelly_ws90_luftfeuchtigkeit\"}",
"expr": "homeassistant_sensor_humidity_percent{entity=\"sensor.shelly_ws90_humidity\"}",
"legendFormat": "Relative Humidity",
"refId": "F"
},
@@ -472,7 +472,7 @@ data:
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [
{
"expr": "homeassistant_sensor_atmospheric_pressure_hpa{entity=\"sensor.shelly_ws90_atmospharischer_druck\"}",
"expr": "homeassistant_sensor_atmospheric_pressure_hpa{entity=\"sensor.shelly_ws90_pressure\"}",
"legendFormat": "Pressure",
"refId": "A"
},
@@ -12,10 +12,10 @@ data:
{
"uid": "taupi-fan",
"title": "💨 Cellar Fan (TAUPI)",
"tags": ["climate", "fan", "cellar", "prometheus"],
"tags": ["climate", "fan", "cellar", "taupi", "prometheus"],
"timezone": "browser",
"schemaVersion": 39,
"version": 3,
"schemaVersion": 41,
"version": 5,
"refresh": "30s",
"editable": false,
"graphTooltip": 2,
@@ -23,22 +23,39 @@ data:
"from": "now-24h",
"to": "now"
},
"templating": {
"list": [
{
"name": "datasource",
"type": "datasource",
"pluginId": "prometheus",
"includeAll": false,
"label": "Prometheus",
"current": {
"selected": true,
"text": "prometheus",
"value": "prometheus"
}
}
]
},
"panels": [
{
"title": "⚡ Status & Controls",
"title": "⚡ Status & Real-Time KPIs",
"type": "row",
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 0},
"collapsed": false
},
{
"id": 1,
"title": "Fan Power Relay",
"type": "stat",
"datasource": {"type": "prometheus", "uid": "prometheus"},
"gridPos": {"h": 4, "w": 6, "x": 0, "y": 1},
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"gridPos": {"h": 4, "w": 4, "x": 0, "y": 1},
"fieldConfig": {
"defaults": {
"mappings": [
{"type": "value", "options": {"0": {"text": "OFF", "color": "blue"}, "1": {"text": "ON", "color": "orange"}}}
{"type": "value", "options": {"0": {"text": "OFF (Idle)", "color": "blue"}, "1": {"text": "VENTILATING", "color": "orange"}}}
],
"color": {"mode": "thresholds"}
}
@@ -50,18 +67,86 @@ data:
"reduceOptions": {"values": false, "calcs": ["lastNotNull"]}
},
"targets": [
{"expr": "taupi_relay_status", "legendFormat": "Fan Status", "refId": "A"}
{"expr": "taupi_relay_status", "legendFormat": "Relay", "refId": "A"}
]
},
{
"title": "Mold Danger Status",
"id": 2,
"title": "Drying Quality Score",
"description": "Calculated 0-100 score based on Dewpoint Spread. Higher score allows longer continuous ventilation runtime.",
"type": "stat",
"datasource": {"type": "prometheus", "uid": "prometheus"},
"gridPos": {"h": 4, "w": 6, "x": 6, "y": 1},
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"gridPos": {"h": 4, "w": 4, "x": 4, "y": 1},
"fieldConfig": {
"defaults": {
"unit": "none",
"min": 0,
"max": 100,
"color": {"mode": "thresholds"},
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "gray", "value": null},
{"color": "yellow", "value": 40},
{"color": "green", "value": 70},
{"color": "purple", "value": 90}
]
}
}
},
"options": {
"graphMode": "none",
"colorMode": "background",
"textMode": "auto",
"reduceOptions": {"values": false, "calcs": ["lastNotNull"]}
},
"targets": [
{"expr": "taupi_quality_score", "legendFormat": "Quality Score", "refId": "A"}
]
},
{
"id": 3,
"title": "Dewpoint Drying Spread",
"description": "Indoor Dewpoint − Outdoor Dewpoint. Must be > 2.0°C for drying.",
"type": "stat",
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"gridPos": {"h": 4, "w": 4, "x": 8, "y": 1},
"fieldConfig": {
"defaults": {
"unit": "celsius",
"decimals": 1,
"color": {"mode": "thresholds"},
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "red", "value": null},
{"color": "yellow", "value": 0},
{"color": "green", "value": 2.0}
]
}
}
},
"options": {
"graphMode": "none",
"colorMode": "background",
"textMode": "auto",
"reduceOptions": {"values": false, "calcs": ["lastNotNull"]}
},
"targets": [
{"expr": "taupi_dewpoint_spread_celsius", "legendFormat": "Dewpoint Spread", "refId": "A"}
]
},
{
"id": 4,
"title": "Thermal Governor State",
"description": "Thermal operating regime (Warm Cooling, Comfort 14-20°C, Cold Restricted, Frost Floor)",
"type": "stat",
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"gridPos": {"h": 4, "w": 4, "x": 12, "y": 1},
"fieldConfig": {
"defaults": {
"mappings": [
{"type": "value", "options": {"0": {"text": "SAFE", "color": "green"}, "1": {"text": "CRITICAL", "color": "red"}}}
{"type": "value", "options": {"0": {"text": "INIT", "color": "gray"}, "1": {"text": "FROST FLOOR", "color": "blue"}, "2": {"text": "COMFORT (14-20°)", "color": "green"}, "3": {"text": "WARM (Cooling)", "color": "orange"}, "4": {"text": "COLD RESTRICTED", "color": "purple"}}}
]
}
},
@@ -72,19 +157,30 @@ data:
"reduceOptions": {"values": false, "calcs": ["lastNotNull"]}
},
"targets": [
{"expr": "taupi_is_critical", "legendFormat": "Mold Danger", "refId": "A"}
{"expr": "taupi_thermal_governor_state", "legendFormat": "Governor", "refId": "A"}
]
},
{
"title": "Activation Criteria Met",
"id": 5,
"title": "Dynamic Max Burst Runtime",
"description": "Dynamic max continuous runtime allowed based on drying quality",
"type": "stat",
"datasource": {"type": "prometheus", "uid": "prometheus"},
"gridPos": {"h": 4, "w": 6, "x": 12, "y": 1},
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"gridPos": {"h": 4, "w": 4, "x": 16, "y": 1},
"fieldConfig": {
"defaults": {
"mappings": [
{"type": "value", "options": {"0": {"text": "NO", "color": "gray"}, "1": {"text": "YES", "color": "green"}}}
]
"unit": "m",
"decimals": 0,
"color": {"mode": "thresholds"},
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "gray", "value": null},
{"color": "yellow", "value": 20},
{"color": "green", "value": 45},
{"color": "purple", "value": 90}
]
}
}
},
"options": {
@@ -94,14 +190,15 @@ data:
"reduceOptions": {"values": false, "calcs": ["lastNotNull"]}
},
"targets": [
{"expr": "taupi_would_fan_activate", "legendFormat": "Dewpoint Condition", "refId": "A"}
{"expr": "taupi_max_allowed_runtime_minutes", "legendFormat": "Max Runtime", "refId": "A"}
]
},
{
"title": "Shelly Plug Reachability",
"id": 6,
"title": "Controller Connectivity",
"type": "stat",
"datasource": {"type": "prometheus", "uid": "prometheus"},
"gridPos": {"h": 4, "w": 6, "x": 18, "y": 1},
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"gridPos": {"h": 4, "w": 4, "x": 20, "y": 1},
"fieldConfig": {
"defaults": {
"mappings": [
@@ -116,23 +213,113 @@ data:
"reduceOptions": {"values": false, "calcs": ["lastNotNull"]}
},
"targets": [
{"expr": "up{job=\"taupi-fan\"}", "legendFormat": "Connectivity", "refId": "A"}
{"expr": "up{job=\"taupi-fan\"}", "legendFormat": "Shelly Scrape", "refId": "A"}
]
},
{
"title": "📊 Climate Correlation",
"title": "🎯 PRIMARY GOAL: Humidity Reduction vs. Fan Operation",
"type": "row",
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 5},
"collapsed": false
},
{
"id": 7,
"title": "🎯 Primary Goal: Humidity High ➔ Fan Runs ➔ Humidity Drops",
"description": "Visual validation of cellar drying: When Indoor Humidity rises above target, the orange Fan Active band engages until Indoor Humidity drops back down.",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"gridPos": {"h": 12, "w": 24, "x": 0, "y": 6},
"fieldConfig": {
"defaults": {
"unit": "percent",
"custom": {
"lineWidth": 2,
"fillOpacity": 0,
"spanNulls": true,
"showPoints": "never"
}
},
"overrides": [
{
"matcher": {"id": "byName", "options": "Fan Active (Running)"},
"properties": [
{"id": "color", "value": {"fixedColor": "#FF9800", "mode": "fixed"}},
{"id": "custom.axisPlacement", "value": "hidden"},
{"id": "custom.drawStyle", "value": "line"},
{"id": "custom.lineInterpolation", "value": "stepAfter"},
{"id": "custom.lineWidth", "value": 0},
{"id": "custom.fillOpacity", "value": 25},
{"id": "min", "value": 0},
{"id": "max", "value": 1},
{"id": "decimals", "value": 0},
{"id": "mappings", "value": [{"type": "value", "options": {"0": {"text": "OFF"}, "1": {"text": "ON"}}}]}
]
},
{
"matcher": {"id": "byName", "options": "Indoor Humidity (Cellar)"},
"properties": [
{"id": "custom.axisPlacement", "value": "left"},
{"id": "color", "value": {"fixedColor": "#00E5FF", "mode": "fixed"}},
{"id": "custom.lineWidth", "value": 3},
{"id": "custom.fillOpacity", "value": 12}
]
},
{
"matcher": {"id": "byName", "options": "Target Setpoint"},
"properties": [
{"id": "custom.axisPlacement", "value": "left"},
{"id": "color", "value": {"fixedColor": "#E040FB", "mode": "fixed"}},
{"id": "custom.lineStyle", "value": {"fill": "dash", "dash": [6, 4]}},
{"id": "custom.lineWidth", "value": 2}
]
},
{
"matcher": {"id": "byName", "options": "Mold Risk Limit (Sedlbauer)"},
"properties": [
{"id": "custom.axisPlacement", "value": "left"},
{"id": "color", "value": {"fixedColor": "#FF1744", "mode": "fixed"}},
{"id": "custom.lineStyle", "value": {"fill": "dash", "dash": [10, 5]}},
{"id": "custom.lineWidth", "value": 1.5}
]
},
{
"matcher": {"id": "byName", "options": "Outdoor Humidity"},
"properties": [
{"id": "custom.axisPlacement", "value": "left"},
{"id": "color", "value": {"fixedColor": "#448AFF", "mode": "fixed"}},
{"id": "custom.lineWidth", "value": 1.5},
{"id": "custom.lineStyle", "value": {"fill": "dot", "dash": [2, 4]}}
]
}
]
},
"options": {
"legend": {"displayMode": "table", "placement": "bottom", "calcs": ["lastNotNull", "min", "max", "mean"]},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [
{"expr": "taupi_relay_status", "legendFormat": "Fan Active (Running)", "refId": "A"},
{"expr": "taupi_humidity_percent_innen", "legendFormat": "Indoor Humidity (Cellar)", "refId": "B"},
{"expr": "taupi_target_humidity_percent", "legendFormat": "Target Setpoint", "refId": "C"},
{"expr": "taupi_critical_humidity_threshold_percent", "legendFormat": "Mold Risk Limit (Sedlbauer)", "refId": "D"},
{"expr": "taupi_humidity_percent_aussen", "legendFormat": "Outdoor Humidity", "refId": "E"}
]
},
{
"title": "📊 Full Climate & Fan Correlation",
"description": "All metrics in one view. Left axis: temperatures & dewpoints (°C). Right axis: humidity & mold threshold (%). Colored background bands: orange = fan running, red = mold critical, green = activation criteria met. Hover anywhere to read all values at that timestamp.",
"title": "📊 Complete Climate & Psychrometric Correlation",
"type": "row",
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 18},
"collapsed": false
},
{
"id": 8,
"title": "📊 Full Telemetry Overview (Temperatures, Dewpoints, Humidity & Fan)",
"description": "Comprehensive correlation: Left axis (°C) = Temperatures & Dewpoints. Right axis (%) = Humidity, Setpoint & Mold Limit. Orange shaded band = Fan Relay ON.",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "prometheus"},
"gridPos": {"h": 14, "w": 24, "x": 0, "y": 6},
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"gridPos": {"h": 13, "w": 24, "x": 0, "y": 19},
"fieldConfig": {
"defaults": {
"custom": {
@@ -151,43 +338,13 @@ data:
{"id": "custom.drawStyle", "value": "line"},
{"id": "custom.lineInterpolation", "value": "stepAfter"},
{"id": "custom.lineWidth", "value": 0},
{"id": "custom.fillOpacity", "value": 15},
{"id": "custom.fillOpacity", "value": 20},
{"id": "min", "value": 0},
{"id": "max", "value": 1},
{"id": "decimals", "value": 0},
{"id": "mappings", "value": [{"type": "value", "options": {"0": {"text": "OFF"}, "1": {"text": "ON"}}}]}
]
},
{
"matcher": {"id": "byName", "options": "Mold Critical"},
"properties": [
{"id": "color", "value": {"fixedColor": "#F44336", "mode": "fixed"}},
{"id": "custom.axisPlacement", "value": "hidden"},
{"id": "custom.drawStyle", "value": "line"},
{"id": "custom.lineInterpolation", "value": "stepAfter"},
{"id": "custom.lineWidth", "value": 0},
{"id": "custom.fillOpacity", "value": 10},
{"id": "min", "value": 0},
{"id": "max", "value": 1},
{"id": "decimals", "value": 0},
{"id": "mappings", "value": [{"type": "value", "options": {"0": {"text": "SAFE"}, "1": {"text": "CRITICAL"}}}]}
]
},
{
"matcher": {"id": "byName", "options": "Activation Criteria"},
"properties": [
{"id": "color", "value": {"fixedColor": "#4CAF50", "mode": "fixed"}},
{"id": "custom.axisPlacement", "value": "hidden"},
{"id": "custom.drawStyle", "value": "line"},
{"id": "custom.lineInterpolation", "value": "stepAfter"},
{"id": "custom.lineWidth", "value": 0},
{"id": "custom.fillOpacity", "value": 8},
{"id": "min", "value": 0},
{"id": "max", "value": 1},
{"id": "decimals", "value": 0},
{"id": "mappings", "value": [{"type": "value", "options": {"0": {"text": "NO"}, "1": {"text": "YES"}}}]}
]
},
{
"matcher": {"id": "byName", "options": "Indoor Temp"},
"properties": [
@@ -211,9 +368,9 @@ data:
"properties": [
{"id": "custom.axisPlacement", "value": "left"},
{"id": "unit", "value": "celsius"},
{"id": "color", "value": {"fixedColor": "#FF6D00", "mode": "fixed"}},
{"id": "custom.lineStyle", "value": {"fill": "dash", "dash": [10, 5]}},
{"id": "custom.lineWidth", "value": 1}
{"id": "color", "value": {"fixedColor": "#FFB74D", "mode": "fixed"}},
{"id": "custom.lineStyle", "value": {"fill": "dash", "dash": [8, 4]}},
{"id": "custom.lineWidth", "value": 1.5}
]
},
{
@@ -221,9 +378,9 @@ data:
"properties": [
{"id": "custom.axisPlacement", "value": "left"},
{"id": "unit", "value": "celsius"},
{"id": "color", "value": {"fixedColor": "#2979FF", "mode": "fixed"}},
{"id": "custom.lineStyle", "value": {"fill": "dash", "dash": [10, 5]}},
{"id": "custom.lineWidth", "value": 1}
{"id": "color", "value": {"fixedColor": "#90CAF9", "mode": "fixed"}},
{"id": "custom.lineStyle", "value": {"fill": "dash", "dash": [8, 4]}},
{"id": "custom.lineWidth", "value": 1.5}
]
},
{
@@ -231,9 +388,8 @@ data:
"properties": [
{"id": "custom.axisPlacement", "value": "right"},
{"id": "unit", "value": "percent"},
{"id": "color", "value": {"fixedColor": "#00BFA5", "mode": "fixed"}},
{"id": "custom.lineWidth", "value": 2},
{"id": "custom.fillOpacity", "value": 5}
{"id": "color", "value": {"fixedColor": "#00E5FF", "mode": "fixed"}},
{"id": "custom.lineWidth", "value": 2.5}
]
},
{
@@ -245,260 +401,203 @@ data:
{"id": "custom.lineWidth", "value": 1}
]
},
{
"matcher": {"id": "byName", "options": "Target Setpoint"},
"properties": [
{"id": "custom.axisPlacement", "value": "right"},
{"id": "unit", "value": "percent"},
{"id": "color", "value": {"fixedColor": "#E040FB", "mode": "fixed"}},
{"id": "custom.lineStyle", "value": {"fill": "dash", "dash": [6, 4]}},
{"id": "custom.lineWidth", "value": 1.5}
]
},
{
"matcher": {"id": "byName", "options": "Mold Threshold"},
"properties": [
{"id": "custom.axisPlacement", "value": "right"},
{"id": "unit", "value": "percent"},
{"id": "color", "value": {"fixedColor": "#F44336", "mode": "fixed"}},
{"id": "color", "value": {"fixedColor": "#FF1744", "mode": "fixed"}},
{"id": "custom.lineStyle", "value": {"fill": "dash", "dash": [10, 5]}},
{"id": "custom.lineWidth", "value": 1}
{"id": "custom.lineWidth", "value": 1.5}
]
}
]
},
"options": {
"legend": {"displayMode": "table", "placement": "bottom", "calcs": ["lastNotNull", "min", "max"]},
"legend": {"displayMode": "table", "placement": "bottom", "calcs": ["lastNotNull", "min", "max", "mean"]},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [
{"expr": "taupi_relay_status", "legendFormat": "Fan Active", "refId": "A"},
{"expr": "taupi_is_critical", "legendFormat": "Mold Critical", "refId": "B"},
{"expr": "taupi_would_fan_activate", "legendFormat": "Activation Criteria", "refId": "C"},
{"expr": "taupi_temperature_celsius_innen", "legendFormat": "Indoor Temp", "refId": "D"},
{"expr": "taupi_temperature_celsius_aussen", "legendFormat": "Outdoor Temp", "refId": "E"},
{"expr": "taupi_dewpoint_celsius_innen", "legendFormat": "Indoor Dewpoint", "refId": "F"},
{"expr": "taupi_dewpoint_celsius_aussen", "legendFormat": "Outdoor Dewpoint", "refId": "G"},
{"expr": "taupi_humidity_percent_innen", "legendFormat": "Indoor Humidity", "refId": "H"},
{"expr": "taupi_humidity_percent_aussen", "legendFormat": "Outdoor Humidity", "refId": "I"},
{"expr": "taupi_critical_humidity_threshold_percent", "legendFormat": "Mold Threshold", "refId": "J"}
{"expr": "taupi_temperature_celsius_innen", "legendFormat": "Indoor Temp", "refId": "B"},
{"expr": "taupi_temperature_celsius_aussen", "legendFormat": "Outdoor Temp", "refId": "C"},
{"expr": "taupi_dewpoint_celsius_innen", "legendFormat": "Indoor Dewpoint", "refId": "D"},
{"expr": "taupi_dewpoint_celsius_aussen", "legendFormat": "Outdoor Dewpoint", "refId": "E"},
{"expr": "taupi_humidity_percent_innen", "legendFormat": "Indoor Humidity", "refId": "F"},
{"expr": "taupi_humidity_percent_aussen", "legendFormat": "Outdoor Humidity", "refId": "G"},
{"expr": "taupi_target_humidity_percent", "legendFormat": "Target Setpoint", "refId": "H"},
{"expr": "taupi_critical_humidity_threshold_percent", "legendFormat": "Mold Threshold", "refId": "I"}
]
},
{
"title": "📈 Deep Dive",
"title": "💧 Psychrometric Deltas & Safety Gates",
"type": "row",
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 16},
"collapsed": true,
"panels": [
{
"title": "🌡️ Temperature Differential (Indoor − Outdoor)",
"description": "Positive = cellar warmer than outside (normal). Large delta means the fan can effectively dry the cellar air by exchanging it with drier outside air.",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "prometheus"},
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 17},
"fieldConfig": {
"defaults": {
"unit": "celsius",
"custom": {
"lineWidth": 2,
"fillOpacity": 10,
"spanNulls": true,
"showPoints": "never",
"gradientMode": "scheme",
"thresholdsStyle": {"mode": "area"}
},
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "blue", "value": null},
{"color": "green", "value": 0},
{"color": "orange", "value": 5}
]
}
}
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 32},
"collapsed": false
},
{
"id": 9,
"title": "💧 Dew Point Spread (Indoor − Outdoor) — Physical Drying Potential",
"description": "Calculates physical moisture removal capacity. When Spread > 2.0°C (Green zone), outside air is dry enough to absorb moisture from the cellar.",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 33},
"fieldConfig": {
"defaults": {
"unit": "celsius",
"custom": {
"lineWidth": 2,
"fillOpacity": 15,
"spanNulls": true,
"showPoints": "never",
"gradientMode": "scheme",
"thresholdsStyle": {"mode": "area"}
},
"options": {
"legend": {"displayMode": "table", "placement": "bottom", "calcs": ["min", "max", "mean", "lastNotNull"]},
"tooltip": {"mode": "multi", "sort": "desc"}
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "#F44336", "value": null},
{"color": "#FFC107", "value": 0},
{"color": "#4CAF50", "value": 2.0}
]
}
}
},
"options": {
"legend": {"displayMode": "table", "placement": "bottom", "calcs": ["min", "max", "mean", "lastNotNull"]},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [
{"expr": "taupi_dewpoint_spread_celsius", "legendFormat": "Dewpoint Spread (In − Out)", "refId": "A"}
]
},
{
"id": 10,
"title": "🌡️ Temperature Delta (Indoor − Outdoor) & Heating Prevention",
"description": "Positive = cellar warmer than outside. Summer heating rule: Fan blocked if Outdoor Temp > 20°C (or > Indoor Temp).",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 33},
"fieldConfig": {
"defaults": {
"unit": "celsius",
"custom": {
"lineWidth": 2,
"fillOpacity": 10,
"spanNulls": true,
"showPoints": "never",
"gradientMode": "scheme",
"thresholdsStyle": {"mode": "area"}
},
"targets": [
{"expr": "taupi_temperature_celsius_innen - taupi_temperature_celsius_aussen", "legendFormat": "Temp Delta (In − Out)", "refId": "A"}
]
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "#2196F3", "value": null},
{"color": "#4CAF50", "value": 0},
{"color": "#FF9800", "value": 5}
]
}
}
},
"options": {
"legend": {"displayMode": "table", "placement": "bottom", "calcs": ["min", "max", "mean", "lastNotNull"]},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [
{"expr": "taupi_temperature_celsius_innen - taupi_temperature_celsius_aussen", "legendFormat": "Temp Delta (In − Out)", "refId": "A"}
]
},
{
"title": "🔋 Hardware Diagnostics & Safety Timers",
"type": "row",
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 41},
"collapsed": false
},
{
"id": 11,
"title": "🔋 BLU Sensor Batteries",
"type": "gauge",
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"gridPos": {"h": 7, "w": 12, "x": 0, "y": 42},
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "red", "value": null},
{"color": "orange", "value": 20},
{"color": "green", "value": 50}
]
}
}
},
"options": {
"reduceOptions": {"values": false, "calcs": ["lastNotNull"]},
"showThresholdLabels": false,
"showThresholdMarkers": true
},
"targets": [
{"expr": "taupi_battery_percent_innen", "legendFormat": "Indoor Sensor Battery", "refId": "A"},
{"expr": "taupi_battery_percent_aussen", "legendFormat": "Outdoor Sensor Battery", "refId": "B"}
]
},
{
"id": 12,
"title": "⏱️ Duty Cycle & Staleness Timers",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"gridPos": {"h": 7, "w": 12, "x": 12, "y": 42},
"fieldConfig": {
"defaults": {
"unit": "s",
"custom": {
"lineWidth": 2,
"fillOpacity": 5,
"spanNulls": true,
"showPoints": "never"
}
},
{
"title": "💧 Dewpoint Spread (Indoor − Outdoor)",
"description": "When indoor dewpoint exceeds outdoor dewpoint, running the fan helps: outside air can absorb moisture. Negative = fan would make things worse.",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "prometheus"},
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 17},
"fieldConfig": {
"defaults": {
"unit": "celsius",
"custom": {
"lineWidth": 2,
"fillOpacity": 10,
"spanNulls": true,
"showPoints": "never",
"gradientMode": "scheme",
"thresholdsStyle": {"mode": "area"}
},
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "red", "value": null},
{"color": "green", "value": 0},
{"color": "super-light-green", "value": 3}
]
}
}
},
"options": {
"legend": {"displayMode": "table", "placement": "bottom", "calcs": ["min", "max", "mean", "lastNotNull"]},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [
{"expr": "taupi_dewpoint_celsius_innen - taupi_dewpoint_celsius_aussen", "legendFormat": "Dewpoint Delta (In − Out)", "refId": "A"}
]
},
{
"title": "💧 Humidity Delta (Indoor − Outdoor)",
"description": "How much more humid the cellar is compared to outside. High positive values indicate the fan can effectively remove moisture.",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "prometheus"},
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 25},
"fieldConfig": {
"defaults": {
"unit": "percent",
"custom": {
"lineWidth": 2,
"fillOpacity": 10,
"spanNulls": true,
"showPoints": "never",
"gradientMode": "scheme",
"thresholdsStyle": {"mode": "area"}
},
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "blue", "value": null},
{"color": "green", "value": 0},
{"color": "orange", "value": 15}
]
}
}
},
"options": {
"legend": {"displayMode": "table", "placement": "bottom", "calcs": ["min", "max", "mean", "lastNotNull"]},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [
{"expr": "taupi_humidity_percent_innen - taupi_humidity_percent_aussen", "legendFormat": "Humidity Delta (In − Out)", "refId": "A"}
]
},
{
"title": "📉 Indoor Humidity vs Fan Runtime",
"description": "Overlay of indoor humidity (left axis) and fan state (right axis). Shows the direct effect: when the orange fan-on band appears, does the teal humidity line drop?",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "prometheus"},
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 25},
"fieldConfig": {
"defaults": {
"custom": {
"lineWidth": 2,
"spanNulls": true,
"showPoints": "never"
}
},
"overrides": [
{
"matcher": {"id": "byName", "options": "Indoor Humidity"},
"properties": [
{"id": "custom.axisPlacement", "value": "left"},
{"id": "unit", "value": "percent"},
{"id": "color", "value": {"fixedColor": "#00BFA5", "mode": "fixed"}},
{"id": "custom.fillOpacity", "value": 10}
]
},
{
"matcher": {"id": "byName", "options": "Fan Active"},
"properties": [
{"id": "custom.axisPlacement", "value": "right"},
{"id": "custom.drawStyle", "value": "line"},
{"id": "custom.lineInterpolation", "value": "stepAfter"},
{"id": "custom.fillOpacity", "value": 30},
{"id": "color", "value": {"fixedColor": "orange", "mode": "fixed"}},
{"id": "min", "value": 0},
{"id": "max", "value": 1},
{"id": "decimals", "value": 0},
{"id": "mappings", "value": [{"type": "value", "options": {"0": {"text": "OFF"}, "1": {"text": "ON"}}}]}
]
}
"overrides": [
{
"matcher": {"id": "byName", "options": "Fan Running Time"},
"properties": [
{"id": "color", "value": {"fixedColor": "#FF9800", "mode": "fixed"}},
{"id": "custom.lineWidth", "value": 2}
]
},
"options": {
"legend": {"displayMode": "table", "placement": "bottom", "calcs": ["min", "max", "mean", "lastNotNull"]},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [
{"expr": "taupi_humidity_percent_innen", "legendFormat": "Indoor Humidity", "refId": "A"},
{"expr": "taupi_relay_status", "legendFormat": "Fan Active", "refId": "B"}
]
}
]
},
{
"title": "🔋 BLE Sensors Diagnostic",
"type": "row",
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 17},
"collapsed": true,
"panels": [
{
"title": "Sensor Batteries",
"type": "gauge",
"datasource": {"type": "prometheus", "uid": "prometheus"},
"gridPos": {"h": 6, "w": 12, "x": 0, "y": 18},
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "red", "value": null},
{"color": "orange", "value": 20},
{"color": "green", "value": 50}
]
}
}
},
"options": {
"reduceOptions": {"values": false, "calcs": ["lastNotNull"]},
"showThresholdLabels": false,
"showThresholdMarkers": true
},
"targets": [
{"expr": "taupi_battery_percent_innen", "legendFormat": "Indoor Sensor Battery", "refId": "A"},
{"expr": "taupi_battery_percent_aussen", "legendFormat": "Outdoor Sensor Battery", "refId": "B"}
]
},
{
"title": "Sensor Connection Age (Staleness)",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "prometheus"},
"gridPos": {"h": 6, "w": 12, "x": 12, "y": 18},
"fieldConfig": {
"defaults": {
"unit": "s",
"custom": {
"lineWidth": 2,
"fillOpacity": 5,
"spanNulls": true,
"showPoints": "never"
}
}
},
"options": {
"legend": {"displayMode": "table", "placement": "bottom", "calcs": ["lastNotNull"]},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [
{"expr": "taupi_lost_connection_seconds_innen", "legendFormat": "Indoor Connection Stale Time", "refId": "A"},
{"expr": "taupi_lost_connection_seconds_aussen", "legendFormat": "Outdoor Connection Stale Time", "refId": "B"}
]
}
{
"matcher": {"id": "byName", "options": "Cooldown Pause Remaining"},
"properties": [
{"id": "color", "value": {"fixedColor": "#AB47BC", "mode": "fixed"}},
{"id": "custom.lineWidth", "value": 2}
]
}
]
},
"options": {
"legend": {"displayMode": "table", "placement": "bottom", "calcs": ["lastNotNull", "max"]},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [
{"expr": "taupi_fan_running_seconds", "legendFormat": "Fan Running Time", "refId": "A"},
{"expr": "taupi_fan_pause_remaining_seconds", "legendFormat": "Cooldown Pause Remaining", "refId": "B"},
{"expr": "taupi_lost_connection_seconds_innen", "legendFormat": "Indoor Signal Age", "refId": "C"},
{"expr": "taupi_lost_connection_seconds_aussen", "legendFormat": "Outdoor Signal Age", "refId": "D"}
]
}
]
+77 -15
View File
@@ -1,18 +1,80 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: nextcloud-phpmyadmin
namespace: nextcloud
spec:
replicas: 1
selector:
matchLabels:
app: nextcloud-phpmyadmin
template:
metadata:
labels:
app: nextcloud-phpmyadmin
spec:
containers:
- name: phpmyadmin
image: phpmyadmin:5.2.1
ports:
- containerPort: 80
name: http
env:
- name: PMA_HOST
value: "nextcloud-mariadb"
- name: PMA_PORT
value: "3306"
- name: PMA_USER
value: "root"
- name: PMA_PASSWORD
value: "aeriukuow8shaiHoh4aimie5u"
- name: PMA_ARBITRARY
value: "0"
resources:
requests:
cpu: 10m
memory: 64Mi
limits:
memory: 256Mi
---
db:
host: nextcloud-mariadb
ingress:
enabled: true
hostname: "nextcloud.phpmyadmin.haumdaucher.de"
tls: "true"
ingressClassName: "traefik"
# hosts:
# - path: "/"
# tls: true
# name: "nextcloud.phpmyadmin.haumdaucher.de"
# tlsSecret: "nextcloud-phpmyadmin-haumdaucher-de"
apiVersion: v1
kind: Service
metadata:
name: nextcloud-phpmyadmin
namespace: nextcloud
spec:
type: ClusterIP
ports:
- port: 80
targetPort: 80
name: http
selector:
app: nextcloud-phpmyadmin
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: nextcloud-phpmyadmin
namespace: nextcloud
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/proxy-body-size: "0"
kubernetes.io/ingress.class: traefik
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
kubernetes.io/ingress.class: "traefik"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
traefik.ingress.kubernetes.io/router.middlewares: auth-authentik-forwardauth@kubernetescrd
spec:
ingressClassName: traefik
tls:
- hosts:
- nextcloud.phpmyadmin.haumdaucher.de
secretName: nextcloud-phpmyadmin-haumdaucher-de
rules:
- host: nextcloud.phpmyadmin.haumdaucher.de
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: nextcloud-phpmyadmin
port:
number: 80
Binary file not shown.
Binary file not shown.
+4 -4
View File
@@ -26,7 +26,7 @@ ingress:
nginx.ingress.kubernetes.io/proxy-body-size: "0"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
traefik.ingress.kubernetes.io/router.middlewares: zigbee-zigbee2mqtt-auth@kubernetescrd
traefik.ingress.kubernetes.io/router.middlewares: auth-authentik-forwardauth@kubernetescrd
hosts:
- host: zigbee2mqtt.moritzgraf.de
paths:
@@ -74,7 +74,7 @@ zigbee2mqtt:
log_directories_to_keep: 100
adapter_concurrent: 8
adapter_delay: 100
transmit_power: 20
transmit_power: 5
device_options: {}
extraResources:
@@ -115,10 +115,10 @@ extraResources:
summary: "Zigbee2MQTT Bridge Connection Offline"
description: "The Zigbee2MQTT bridge connection is reported as offline. The bridge has lost communication with the MQTT broker or the Zigbee coordinator."
- alert: ShellyWS90MetricsStale
expr: absent(homeassistant_sensor_temperature_celsius{entity="sensor.shelly_ws90_temperatur"}) or (time() - max(homeassistant_last_updated_time_seconds{entity=~"sensor.shelly_ws90_.*"}) > 3600)
expr: absent(homeassistant_sensor_temperature_celsius{entity="sensor.shelly_ws90_temperature"}) or (time() - max(homeassistant_last_updated_time_seconds{entity=~"sensor.shelly_ws90_.*"}) > 3600)
for: 1h
labels:
severity: warning
annotations:
summary: "Shelly WS90 Weather Station metrics are stale"
description: "No new metrics have been received from the Shelly WS90 weather station (sensor.shelly_ws90_temperatur) for over 1 hour. The device may be offline, out of range, or batteries might be depleted."
description: "No new metrics have been received from the Shelly WS90 weather station (sensor.shelly_ws90_temperature) for over 1 hour. The device may be offline, out of range, or batteries might be depleted."