Add pre-commit with detect-secrets, ignore gitea runtime data

This commit is contained in:
blasebast 2026-04-11 10:34:50 +02:00
parent a80b18bef1
commit cd74264bb5
32 changed files with 1418 additions and 203 deletions

3
.gitignore vendored
View File

@ -277,5 +277,8 @@ side-agent/manifest/
.claude/
CLAUDE.md
# Gitea runtime data (sessions, SSH keys, databases)
gitea/
# OTHERS
log-ingester/state/state.json

14
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,14 @@
repos:
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
args: ['--baseline', '.secrets.baseline']
exclude: ^\.env.*
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: check-added-large-files
- id: check-merge-conflict
- id: detect-private-key

308
.secrets.baseline Normal file
View File

@ -0,0 +1,308 @@
{
"version": "1.5.0",
"plugins_used": [
{
"name": "ArtifactoryDetector"
},
{
"name": "AWSKeyDetector"
},
{
"name": "AzureStorageKeyDetector"
},
{
"name": "Base64HighEntropyString",
"limit": 4.5
},
{
"name": "BasicAuthDetector"
},
{
"name": "CloudantDetector"
},
{
"name": "DiscordBotTokenDetector"
},
{
"name": "GitHubTokenDetector"
},
{
"name": "GitLabTokenDetector"
},
{
"name": "HexHighEntropyString",
"limit": 3.0
},
{
"name": "IbmCloudIamDetector"
},
{
"name": "IbmCosHmacDetector"
},
{
"name": "IPPublicDetector"
},
{
"name": "JwtTokenDetector"
},
{
"name": "KeywordDetector",
"keyword_exclude": ""
},
{
"name": "MailchimpDetector"
},
{
"name": "NpmDetector"
},
{
"name": "OpenAIDetector"
},
{
"name": "PrivateKeyDetector"
},
{
"name": "PypiTokenDetector"
},
{
"name": "SendGridDetector"
},
{
"name": "SlackDetector"
},
{
"name": "SoftlayerDetector"
},
{
"name": "SquareOAuthDetector"
},
{
"name": "StripeDetector"
},
{
"name": "TelegramBotTokenDetector"
},
{
"name": "TwilioKeyDetector"
}
],
"filters_used": [
{
"path": "detect_secrets.filters.allowlist.is_line_allowlisted"
},
{
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
"min_level": 2
},
{
"path": "detect_secrets.filters.heuristic.is_indirect_reference"
},
{
"path": "detect_secrets.filters.heuristic.is_likely_id_string"
},
{
"path": "detect_secrets.filters.heuristic.is_lock_file"
},
{
"path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string"
},
{
"path": "detect_secrets.filters.heuristic.is_potential_uuid"
},
{
"path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign"
},
{
"path": "detect_secrets.filters.heuristic.is_sequential_string"
},
{
"path": "detect_secrets.filters.heuristic.is_swagger_file"
},
{
"path": "detect_secrets.filters.heuristic.is_templated_secret"
}
],
"results": {
"crowdsec/conf/dev.yaml": [
{
"type": "Secret Keyword",
"filename": "crowdsec/conf/dev.yaml",
"hashed_secret": "3d6a86d0b53584491e13b12173441f0b945307d2",
"is_verified": false,
"line_number": 24
}
],
"crowdsec/conf/user.yaml": [
{
"type": "Secret Keyword",
"filename": "crowdsec/conf/user.yaml",
"hashed_secret": "3d6a86d0b53584491e13b12173441f0b945307d2",
"is_verified": false,
"line_number": 21
}
],
"esphome/archive/sonoff-mini-kennel.yaml": [
{
"type": "Secret Keyword",
"filename": "esphome/archive/sonoff-mini-kennel.yaml",
"hashed_secret": "9d53c2aa0d14616ca56c762d72201eadf0f67ab2",
"is_verified": false,
"line_number": 21
}
],
"esphome/archive/spare-sonoff-mini-r2.yaml": [
{
"type": "Base64 High Entropy String",
"filename": "esphome/archive/spare-sonoff-mini-r2.yaml",
"hashed_secret": "efa88fa62d45a21ecc1f21f91058be2100917e29",
"is_verified": false,
"line_number": 14
},
{
"type": "Hex High Entropy String",
"filename": "esphome/archive/spare-sonoff-mini-r2.yaml",
"hashed_secret": "a16d394c06ef4e3ac934f918bcdc05c50c2e0607",
"is_verified": false,
"line_number": 18
},
{
"type": "Secret Keyword",
"filename": "esphome/archive/spare-sonoff-mini-r2.yaml",
"hashed_secret": "a16d394c06ef4e3ac934f918bcdc05c50c2e0607",
"is_verified": false,
"line_number": 18
},
{
"type": "Secret Keyword",
"filename": "esphome/archive/spare-sonoff-mini-r2.yaml",
"hashed_secret": "09e1ed8cf851cd6b3584644185f9ae97658a1b32",
"is_verified": false,
"line_number": 27
}
],
"esphome/esp_woox_dining.yaml": [
{
"type": "Secret Keyword",
"filename": "esphome/esp_woox_dining.yaml",
"hashed_secret": "6411188dcabc641aea51512f70ae89afa8d01386",
"is_verified": false,
"line_number": 24
}
],
"frigate/backup_config.yaml": [
{
"type": "Secret Keyword",
"filename": "frigate/backup_config.yaml",
"hashed_secret": "6411188dcabc641aea51512f70ae89afa8d01386",
"is_verified": false,
"line_number": 11
}
],
"gitea/gitea/conf/app.ini": [
{
"type": "Secret Keyword",
"filename": "gitea/gitea/conf/app.ini",
"hashed_secret": "3f13034fb0b1edc6ef85ad01e4417bfac87d6d4a",
"is_verified": false,
"line_number": 27
},
{
"type": "Secret Keyword",
"filename": "gitea/gitea/conf/app.ini",
"hashed_secret": "15b4e547b78c9fb7f88e7fd8aa44ff3d2c43101e",
"is_verified": false,
"line_number": 36
},
{
"type": "JSON Web Token",
"filename": "gitea/gitea/conf/app.ini",
"hashed_secret": "cfbe756d20abb814f15d2168821d1b98532fffa2",
"is_verified": false,
"line_number": 66
},
{
"type": "Secret Keyword",
"filename": "gitea/gitea/conf/app.ini",
"hashed_secret": "f42f039263b8bdcb318abca422615b3decd4a1d0",
"is_verified": false,
"line_number": 67
},
{
"type": "Secret Keyword",
"filename": "gitea/gitea/conf/app.ini",
"hashed_secret": "163ab7bb3489bf9090792cac65aca6ad5ba69486",
"is_verified": false,
"line_number": 101
}
],
"ha-dashboard/auth/pending/craver.json": [
{
"type": "Secret Keyword",
"filename": "ha-dashboard/auth/pending/craver.json",
"hashed_secret": "68eac51b678708b47b959ad9507eea778457c2af",
"is_verified": false,
"line_number": 1
}
],
"ha-dashboard/auth/pending/testuser.json": [
{
"type": "Secret Keyword",
"filename": "ha-dashboard/auth/pending/testuser.json",
"hashed_secret": "f27f25023ad16502f41618cb8d15492c141a35fb",
"is_verified": false,
"line_number": 1
}
],
"homeassistant/config/automations.yaml": [
{
"type": "Hex High Entropy String",
"filename": "homeassistant/config/automations.yaml",
"hashed_secret": "06013aa4c86643f92488f86790b0305800fe67c6",
"is_verified": false,
"line_number": 962
}
],
"mktxp/mktxp.conf": [
{
"type": "Secret Keyword",
"filename": "mktxp/mktxp.conf",
"hashed_secret": "2a5c4352756bc8f740ee8c4cec36bb50601424a6",
"is_verified": false,
"line_number": 6
},
{
"type": "Secret Keyword",
"filename": "mktxp/mktxp.conf",
"hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8",
"is_verified": false,
"line_number": 90
}
],
"prometheus/prometheus.yml": [
{
"type": "Secret Keyword",
"filename": "prometheus/prometheus.yml",
"hashed_secret": "4f7f28f949082724ce21e94114ee60ce285a954b",
"is_verified": false,
"line_number": 34
},
{
"type": "JSON Web Token",
"filename": "prometheus/prometheus.yml",
"hashed_secret": "af98dd665ee30b325ad16d71581a2a99719b7e9b",
"is_verified": false,
"line_number": 54
}
],
"qbit-vpn/config/qBittorrent/qBittorrent.conf": [
{
"type": "Secret Keyword",
"filename": "qbit-vpn/config/qBittorrent/qBittorrent.conf",
"hashed_secret": "3c64501f31a2ed3a77a9f0d307ce6092a4ceb6d2",
"is_verified": false,
"line_number": 51
}
]
},
"generated_at": "2026-04-11T08:30:02Z"
}

64
crowdsec/bin/daily-summary.sh Executable file
View File

@ -0,0 +1,64 @@
#!/bin/bash
# Daily CrowdSec ban summary - Telegram
BOT_TOKEN=$(grep -m1 '^TELEGRAM_BOT_TOKEN=.\+' /home/seba/mydocker/.env | cut -d= -f2-)
CHAT_ID=$(grep -m1 '^TELEGRAM_CHAT_ID=.\+' /home/seba/mydocker/.env | cut -d= -f2-)
TMPFILE=$(mktemp)
docker exec crowdsec cscli decisions list --since 24h -o json > "$TMPFILE" 2>/dev/null
python3 - "$TMPFILE" "$BOT_TOKEN" "$CHAT_ID" << 'PYEOF'
import sys, json, urllib.request, urllib.parse
tmpfile, bot_token, chat_id = sys.argv[1], sys.argv[2], sys.argv[3]
with open(tmpfile) as f:
raw = f.read().strip()
data = json.loads(raw) if raw and raw != 'null' else []
def send(text):
params = urllib.parse.urlencode({
'chat_id': chat_id,
'text': text,
'parse_mode': 'HTML',
'disable_web_page_preview': 'true'
}).encode()
urllib.request.urlopen(f'https://api.telegram.org/bot{bot_token}/sendMessage', params)
if not data:
send('🛡 <b>CrowdSec - raport dobowy</b>\nBrak banów w ostatnich 24h.')
sys.exit(0)
header = f'🚨 <b>CrowdSec — {len(data)} ban(y) z ostatnich 24h</b>\n\n'
entries = []
for alert in data:
src = alert.get('source', {})
decs = alert.get('decisions') or [{}]
dec = decs[0]
ip = src.get('ip', '?')
cn = src.get('cn', '?')
asn = src.get('as_name', '?')
scenario = alert.get('scenario', dec.get('scenario', '?'))
events = alert.get('events_count', '?')
start = alert.get('start_at', '?')[:16].replace('T', ' ')
entries.append(
f'🌍 <code>{ip}</code> [<b>{cn}</b>] {asn}\n'
f'🔒 <code>{scenario}</code>\n'
f'📊 zdarzeń: {events} ⏱ {start}\n'
f'🔗 <a href="https://app.crowdsec.net/cti/{ip}">CTI</a> · '
f'<a href="https://www.whois.com/whois/{ip}">whois</a>\n'
)
msg = header
for entry in entries:
if len(msg) + len(entry) + 1 > 4000:
send(msg.rstrip())
msg = entry + '\n'
else:
msg += entry + '\n'
if msg.strip():
send(msg.rstrip())
PYEOF
rm -f "$TMPFILE"

View File

@ -6,8 +6,6 @@ decisions:
- type: ban
duration: 4h
#duration_expr: Sprintf('%dh', (GetDecisionsCount(Alert.GetValue()) + 1) * 4)
notifications:
- http_default
on_success: break
---
name: default_range_remediation
@ -16,6 +14,4 @@ filters:
decisions:
- type: ban
duration: 4h
notifications:
- http_default
on_success: break

View File

@ -232,6 +232,7 @@ services:
postgresql18:
container_name: postgres18
image: postgres:18
command: ["postgres", "-c", "ssl=on", "-c", "ssl_cert_file=/etc/postgresql/ssl/server.crt", "-c", "ssl_key_file=/etc/postgresql/ssl/server.key"]
restart: always
ports:
- "5432:5432"
@ -241,6 +242,36 @@ services:
POSTGRES_DB: homeassistant
volumes:
- ${PWD}/postgres18/data:/var/lib/postgresql
- ${PWD}/postgresql-ssl:/etc/postgresql/ssl:ro
gitea:
image: gitea/gitea:1.21
container_name: gitea
restart: unless-stopped
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__database__DB_TYPE=postgres
- GITEA__database__HOST=postgresql18:5432
- GITEA__database__NAME=gitea
- GITEA__database__USER=homeassistant
- GITEA__database__PASS=${DB_PASS}
- GITEA__database__SSL_MODE=disable
volumes:
- ./gitea:/data
- /home/seba/git.repos:/import/git.repos:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "3003:3000"
- "2222:22"
labels:
- "traefik.enable=true"
- "traefik.http.routers.gitea.rule=Host(`git.sebson.space`)"
- "traefik.http.routers.gitea.entrypoints=websecure"
- "traefik.http.routers.gitea.tls.certresolver=mytlschallenge"
- "traefik.http.services.gitea.loadbalancer.server.port=3000"
depends_on:
- postgresql18
postgres-exporter:
image: prometheuscommunity/postgres-exporter
@ -387,11 +418,16 @@ services:
volumes:
- ${PWD}/esphome:/config:rw
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "20m"
max-file: "3"
mosquitto:
container_name: mosquitto
restart: unless-stopped
image: eclipse-mosquitto:latest
image: eclipse-mosquitto:2
volumes:
- ${PWD}/mosquitto/config:/mosquitto/config
- ${PWD}/mosquitto/log:/mosquitto/log
@ -400,6 +436,11 @@ services:
network_mode: host
environment:
- TZ=Europe/Prague
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ============================================
# MEDIA
@ -518,7 +559,11 @@ services:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ${PWD}/backup.log:/backup.log:ro
- ${PWD}/ha-dashboard/auth:/auth
- ${PWD}/ha-dashboard/app.py:/app/app.py:ro
- ${PWD}/ha-dashboard/templates:/app/templates:ro
- ${PWD}/ha-dashboard/static:/app/static:ro
- ${PWD}/homeassistant/config:/ha_config:ro
- /home/seba/git.repos/myansible:/myansible:ro
labels:
- "traefik.enable=true"
- "traefik.http.routers.ha-dashboard.rule=Host(`hadash.sebson.space`)"
@ -538,7 +583,7 @@ services:
ports:
- '9989:80'
environment:
ADMIN_TOKEN: ${ENCRYTED_BITWARDEN_ADMIN_TOKEN}
ADMIN_TOKEN: ${ENCRYPTED_BITWARDEN_ADMIN_TOKEN}
DATABASE_URL: postgresql://homeassistant:${DB_PASS}@192.168.1.132:5432/bitwarden
YUBICO_CLIENT_ID: ${YUBICO_CLIENT_ID}
YUBICO_SECRET_KEY: ${YUBICO_SECRET_KEY}

View File

@ -21,17 +21,10 @@ wifi:
ap:
ssid: ${plug_name}
password: 'GulaLikesMcDrive'
password: !secret fallback_ap_password
captive_portal:
ota:
platform: esphome
api:
logger:
level: VERBOSE
packages:
common_core: !include packages/common_core.yaml
web_server:
port: 80

View File

@ -25,11 +25,10 @@ wifi:
ap:
ssid: 'sonoff-office'
password: 'GulaLikesMcDrive'
password: !secret fallback_ap_password
captive_portal:
logger:
packages:
common_core: !include packages/common_core.yaml
web_server:
port: 80
@ -38,11 +37,6 @@ api:
# Opcjonalnie: Jeśli masz problemy z kluczami szyfrowania,
# na czas migracji można to zostawić puste lub dodać encryption key.
ota:
platform: esphome
# Bezpiecznik: jeśli OTA przez HA zawiedzie, zawsze możesz wejść przez WWW
# bo włączyliśmy web_server powyżej.
status_led:
pin:
number: GPIO13

View File

@ -23,18 +23,12 @@ wifi:
ssid: "dining-light-fallback"
password: "guilermo3"
captive_portal:
logger:
api:
ota:
platform: esphome
packages:
common_core: !include packages/common_core.yaml
web_server:
port: 80
status_led:
pin:
number: GPIO13

View File

@ -18,18 +18,8 @@ wifi:
- ssid: !secret wifi_ssid_iot
password: !secret wifi_pw_iot
captive_portal:
# Enable logging
logger:
# Enable Home Assistant API
api:
# Enable over-the-air updates
ota:
platform: esphome
packages:
common_core: !include packages/common_core.yaml
status_led:
pin:

View File

@ -26,22 +26,13 @@ wifi:
ap:
ssid: 'sonoff-driveway'
password: 'GulaLikesMcDrive'
password: !secret fallback_ap_password
packages:
common_core: !include packages/common_core.yaml
web_server:
port: 80
captive_portal:
# Enable logging
logger:
# Enable Home Assistant API
api:
# Enable over-the-air updates
ota:
platform: esphome
status_led:
pin:

View File

@ -0,0 +1,11 @@
# Shared: HA native API, OTA, captive portal, sane log level.
# Per-device YAML wins on duplicate keys (ESPHome merge rules).
captive_portal:
logger:
level: INFO
api:
ota:
platform: esphome

View File

@ -18,16 +18,10 @@ wifi:
ap:
ssid: "kitchen-hood-fallback"
password: "GulaLikesMcDrive"
password: !secret fallback_ap_password
captive_portal:
logger:
api:
ota:
platform: esphome
packages:
common_core: !include packages/common_core.yaml
web_server:
port: 80

View File

@ -19,16 +19,10 @@ wifi:
# Fallback AP
ap:
ssid: "kitchen-led-fallback"
password: "GulaLikesMcDrive"
password: !secret fallback_ap_password
captive_portal:
logger:
api:
ota:
platform: esphome
packages:
common_core: !include packages/common_core.yaml
web_server:
port: 80

View File

@ -24,20 +24,14 @@ wifi:
ap:
ssid: 'garden-pump-fallback'
password: 'GulaLikesMcDrive'
password: !secret fallback_ap_password
captive_portal:
logger:
packages:
common_core: !include packages/common_core.yaml
web_server:
port: 80
api:
ota:
platform: esphome
status_led:
pin:
number: GPIO13

View File

@ -23,22 +23,13 @@ wifi:
# Fallback AP
ap:
ssid: ${dev_name}
password: "GulaLikesMcDrive"
password: !secret fallback_ap_password
packages:
common_core: !include packages/common_core.yaml
web_server:
port: 80
captive_portal:
# Enable logging
logger:
# Enable Home Assistant API
api:
# Enable over-the-air updates
ota:
platform: esphome
# --- HARDWARE CONFIGURATION (SONOFF MINI) ---

View File

@ -23,22 +23,13 @@ wifi:
# Fallback AP
ap:
ssid: ${dev_name}
password: "GulaLikesMcDrive"
password: !secret fallback_ap_password
packages:
common_core: !include packages/common_core.yaml
web_server:
port: 80
captive_portal:
# Enable logging
logger:
# Enable Home Assistant API
api:
# Enable over-the-air updates
ota:
platform: esphome
# --- HARDWARE CONFIGURATION (SONOFF MINI) ---

View File

@ -23,22 +23,13 @@ wifi:
# Fallback AP
ap:
ssid: ${dev_name}
password: "GulaLikesMcDrive"
password: !secret fallback_ap_password
packages:
common_core: !include packages/common_core.yaml
web_server:
port: 80
captive_portal:
# Enable logging
logger:
# Enable Home Assistant API
api:
# Enable over-the-air updates
ota:
platform: esphome
# --- HARDWARE CONFIGURATION (SONOFF MINI) ---

View File

@ -24,23 +24,14 @@ wifi:
# Enable fallback hotspot (captive portal) in case wifi connection fails
ap:
ssid: 'sff-mni-stef'
password: 'GulaLikesMcDrive'
password: !secret fallback_ap_password
packages:
common_core: !include packages/common_core.yaml
web_server:
port: 80
captive_portal:
# Enable logging
logger:
# Enable Home Assistant API
api:
# Enable over-the-air updates
ota:
platform: esphome
status_led:
pin:
number: GPIO13

View File

@ -23,22 +23,13 @@ wifi:
# Fallback AP
ap:
ssid: ${dev_name}
password: "GulaLikesMcDrive"
password: !secret fallback_ap_password
packages:
common_core: !include packages/common_core.yaml
web_server:
port: 80
captive_portal:
# Enable logging
logger:
# Enable Home Assistant API
api:
# Enable over-the-air updates
ota:
platform: esphome
port: 80
# --- HARDWARE CONFIGURATION (3-GANG) ---

View File

@ -11,10 +11,22 @@ from werkzeug.security import generate_password_hash, check_password_hash
app = Flask(__name__)
app.secret_key = os.environ.get("SECRET_KEY", "change-me-in-production")
# Volume-mounted templates must be picked up without restarting the process
app.config["TEMPLATES_AUTO_RELOAD"] = True
AUTH_DIR = os.environ.get("AUTH_DIR", "/auth")
@app.after_request
def _no_store_html(res):
"""Avoid stale dashboard HTML behind proxies / browsers (templates change often)."""
ct = res.headers.get("Content-Type", "")
if ct.startswith("text/html"):
res.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
res.headers["Pragma"] = "no-cache"
return res
# ── Auth helpers ──────────────────────────────────────────────────────────────
def _pending_path(username):
@ -33,6 +45,59 @@ def _get_pending(username):
def _is_approved(username):
return os.path.exists(_approved_path(username))
def _list_dashboard_users():
"""Approved ha-dashboard logins (filenames under auth/approved/)."""
d = os.path.join(AUTH_DIR, "approved")
if not os.path.isdir(d):
return []
names = []
for name in os.listdir(d):
if name.startswith("."):
continue
p = os.path.join(d, name)
if os.path.isfile(p) or os.path.isdir(p):
names.append(name)
return sorted(names, key=str.lower)
def _list_pending_usernames():
"""Registered accounts awaiting approval (pending/*.json, stem = username)."""
d = os.path.join(AUTH_DIR, "pending")
if not os.path.isdir(d):
return []
out = []
for fn in os.listdir(d):
if fn.startswith(".") or not fn.endswith(".json"):
continue
out.append(fn[:-5])
return sorted(out, key=str.lower)
def _nav_user_chips():
"""Footer avatars: current session, then approved, then pending-only (deduped)."""
seen = set()
chips = []
def push(name, role):
key = (name or "").strip().lower()
if not key or key in seen:
return
seen.add(key)
chips.append({"name": name.strip(), "role": role})
cu = session.get("user")
if cu:
push(str(cu), "current")
for u in _list_dashboard_users():
push(u, "approved")
for u in _list_pending_usernames():
push(u, "pending")
return chips
def _save_pending(username, password):
os.makedirs(os.path.join(AUTH_DIR, "pending"), exist_ok=True)
with open(_pending_path(username), "w") as f:
@ -840,7 +905,89 @@ def rzucanie_update(token):
@app.route("/")
@login_required
def index():
return render_template("index.html")
return render_template("index.html", nav_user_chips=_nav_user_chips())
# ── Ansible runs ──────────────────────────────────────────────────────────────
_ANSIBLE_LOG = "/myansible/ansible_runs.jsonl"
@app.route("/api/ansible")
@login_required
def api_ansible():
try:
with open(_ANSIBLE_LOG) as f:
lines = [l.strip() for l in f if l.strip()]
except FileNotFoundError:
return jsonify({"error": "Log not found"})
except Exception as e:
return jsonify({"error": str(e)}), 500
runs_raw = []
for line in lines:
try:
runs_raw.append(json.loads(line))
except Exception:
continue
runs_raw.sort(key=lambda r: r.get("ts", ""), reverse=True)
runs = []
for r in runs_raw[:50]:
hosts = r.get("hosts", {})
total_ok = sum(h.get("ok", 0) for h in hosts.values())
total_changed = sum(h.get("changed", 0) for h in hosts.values())
total_failed = sum(h.get("failed", 0) for h in hosts.values())
runs.append({
"ts": r.get("ts", ""),
"playbook": r.get("playbook", ""),
"user": r.get("user", ""),
"duration": r.get("duration", 0),
"status": r.get("status", "unknown"),
"hosts": r.get("hosts", {}),
"tasks_count": len(r.get("tasks", [])),
"ok": total_ok,
"changed": total_changed,
"failed": total_failed,
})
total = len(runs_raw)
success_count = sum(1 for r in runs_raw if r.get("status") == "success")
failed_count = total - success_count
success_rate = round(success_count / total * 100, 1) if total else 0.0
durations = [r.get("duration", 0) for r in runs_raw if r.get("duration") is not None]
avg_duration = round(sum(durations) / len(durations), 1) if durations else 0.0
by_playbook = {}
for r in runs_raw:
pb = r.get("playbook", "unknown")
if pb not in by_playbook:
by_playbook[pb] = {"count": 0, "failures": 0, "last_run": ""}
by_playbook[pb]["count"] += 1
if r.get("status") == "failed":
by_playbook[pb]["failures"] += 1
ts = r.get("ts", "")
if ts > by_playbook[pb]["last_run"]:
by_playbook[pb]["last_run"] = ts
return jsonify({
"runs": runs,
"stats": {
"total": total,
"success_count": success_count,
"failed_count": failed_count,
"success_rate": success_rate,
"avg_duration": avg_duration,
},
"by_playbook": by_playbook,
})
@app.route("/ansible")
@login_required
def ansible_page():
return render_template("ansible.html", nav_user_chips=_nav_user_chips())
if __name__ == "__main__":

View File

@ -0,0 +1,256 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Ansible Runs</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🚀</text></svg>">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #0f172a; color: #e2e8f0; font-family: 'Segoe UI', system-ui, sans-serif; font-size: 13px; }
header {
padding: 13px 20px; background: #1e293b; border-bottom: 1px solid #334155;
display: flex; align-items: center; flex-wrap: wrap; gap: 12px 16px;
justify-content: space-between;
}
header a { color: #64748b; text-decoration: none; font-size: 12px; }
header a:hover { color: #e2e8f0; }
header h1 { font-size: 16px; font-weight: 600; color: #f1f5f9; flex: 1 1 auto; min-width: 0; }
.header-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 12px; flex-shrink: 0; }
.header-tools .niepal { color: #94a3b8; font-weight: 600; padding: 4px 10px; border-radius: 6px; border: 1px solid #334155; }
.header-tools .niepal:hover { color: #e2e8f0; border-color: #475569; }
.footer-users { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.user-chip {
width: 24px; height: 24px; border-radius: 50%;
background: #0f172a; border: 1px solid #334155;
display: inline-flex; align-items: center; justify-content: center;
font-size: 10px; font-weight: 700; color: #94a3b8;
cursor: default; user-select: none;
}
.user-chip-current { border-color: #2563eb; color: #93c5fd; }
.user-chip-approved { color: #94a3b8; }
.user-chip-pending { border-style: dashed; opacity: 0.75; color: #64748b; }
main {
display: grid; grid-template-columns: repeat(12, 1fr);
gap: 14px; padding: 16px 20px; max-width: 1600px; margin: 0 auto;
}
.col-3 { grid-column: span 3; }
.col-4 { grid-column: span 4; }
.col-6 { grid-column: span 6; }
.col-12 { grid-column: span 12; }
@media (max-width: 1100px) {
.col-3,.col-4,.col-6 { grid-column: span 12; }
}
.card { background: #1e293b; border: 1px solid #334155; border-radius: 10px; padding: 15px; }
.card-title {
font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: .08em; color: #64748b; margin-bottom: 12px;
}
.stat { text-align: center; padding: 8px 0; }
.stat .val { font-size: 32px; font-weight: 700; line-height: 1.1; }
.stat .lbl { font-size: 11px; color: #64748b; margin-top: 5px; }
.green { color: #34d399; }
.red { color: #f87171; }
.amber { color: #fbbf24; }
.blue { color: #60a5fa; }
/* table */
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th {
text-align: left; padding: 6px 8px; font-size: 10px; font-weight: 700;
text-transform: uppercase; letter-spacing: .06em; color: #64748b;
border-bottom: 1px solid #334155;
}
td { padding: 7px 8px; border-bottom: 1px solid #1e293b; color: #cbd5e1; vertical-align: middle; }
tr:hover td { background: #0f172a; }
tr:last-child td { border-bottom: none; }
/* badges */
.badge {
display: inline-block; padding: 2px 8px; border-radius: 4px;
font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em;
}
.badge-ok { background: #064e3b; color: #6ee7b7; }
.badge-fail { background: #4c0519; color: #fca5a5; }
.empty-msg { color: #475569; font-size: 13px; padding: 20px 0; text-align: center; }
</style>
</head>
<body>
<header>
<a href="/">← Dashboard</a>
<h1>Ansible Runs</h1>
<div class="header-tools">
<a href="/niepalenie" class="niepal" title="Niepalenie">🚭 Niepalenie</a>
{% include 'partials/nav_user_chips.html' %}
</div>
</header>
<main>
<!-- stat cards -->
<div class="card col-3">
<div class="card-title">Total Runs</div>
<div class="stat">
<div class="val blue" id="st-total">-</div>
<div class="lbl">runs</div>
</div>
</div>
<div class="card col-3">
<div class="card-title">Success Rate</div>
<div class="stat">
<div class="val green" id="st-rate">-</div>
<div class="lbl">percent</div>
</div>
</div>
<div class="card col-3">
<div class="card-title">Failed Runs</div>
<div class="stat">
<div class="val red" id="st-failed">-</div>
<div class="lbl">runs</div>
</div>
</div>
<div class="card col-3">
<div class="card-title">Avg Duration</div>
<div class="stat">
<div class="val amber" id="st-avg">-</div>
<div class="lbl">seconds</div>
</div>
</div>
<!-- recent runs table -->
<div class="card col-12">
<div class="card-title">Recent Runs (last 50)</div>
<div id="runs-wrap">
<div class="empty-msg">Loading...</div>
</div>
</div>
<!-- per-playbook breakdown -->
<div class="card col-12">
<div class="card-title">Per-Playbook Breakdown</div>
<div id="pb-wrap">
<div class="empty-msg">Loading...</div>
</div>
</div>
</main>
<script>
function fmtTs(ts) {
if (!ts) return '-';
return ts.replace('T', ' ');
}
function fmtDur(s) {
if (s == null) return '-';
if (s < 60) return s.toFixed(1) + 's';
const m = Math.floor(s / 60);
const sec = (s % 60).toFixed(0);
return m + 'm ' + sec + 's';
}
function renderRuns(runs) {
if (!runs || runs.length === 0) {
return '<div class="empty-msg">Brak danych - uruchom playbooka</div>';
}
const rows = runs.map(r => {
const hostNames = Object.keys(r.hosts || {}).join(', ') || '-';
const badge = r.status === 'success'
? '<span class="badge badge-ok">success</span>'
: '<span class="badge badge-fail">failed</span>';
return `<tr>
<td>${fmtTs(r.ts)}</td>
<td>${r.playbook}</td>
<td>${badge}</td>
<td>${fmtDur(r.duration)}</td>
<td>${hostNames}</td>
<td>${r.ok}</td>
<td>${r.changed}</td>
<td>${r.failed}</td>
<td>${r.tasks_count}</td>
</tr>`;
}).join('');
return `<table>
<thead><tr>
<th>Timestamp</th>
<th>Playbook</th>
<th>Status</th>
<th>Duration</th>
<th>Hosts</th>
<th>OK</th>
<th>Changed</th>
<th>Failed</th>
<th>Tasks</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>`;
}
function renderByPlaybook(bp) {
const keys = Object.keys(bp || {});
if (keys.length === 0) {
return '<div class="empty-msg">Brak danych - uruchom playbooka</div>';
}
const rows = keys.sort().map(pb => {
const d = bp[pb];
return `<tr>
<td>${pb}</td>
<td>${d.count}</td>
<td>${d.failures}</td>
<td>${fmtTs(d.last_run)}</td>
</tr>`;
}).join('');
return `<table>
<thead><tr>
<th>Playbook</th>
<th>Runs</th>
<th>Failures</th>
<th>Last Run</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>`;
}
function load() {
fetch('/api/ansible')
.then(r => r.json())
.then(data => {
if (data.error) {
document.getElementById('runs-wrap').innerHTML =
'<div class="empty-msg">Brak danych - uruchom playbooka</div>';
document.getElementById('pb-wrap').innerHTML =
'<div class="empty-msg">Brak danych - uruchom playbooka</div>';
return;
}
const s = data.stats || {};
document.getElementById('st-total').textContent = s.total ?? '-';
document.getElementById('st-rate').textContent = (s.success_rate ?? '-') + '%';
document.getElementById('st-failed').textContent = s.failed_count ?? '-';
document.getElementById('st-avg').textContent = s.avg_duration != null ? fmtDur(s.avg_duration) : '-';
document.getElementById('runs-wrap').innerHTML = renderRuns(data.runs);
document.getElementById('pb-wrap').innerHTML = renderByPlaybook(data.by_playbook);
})
.catch(err => {
console.error(err);
document.getElementById('runs-wrap').innerHTML =
'<div class="empty-msg">Error loading data</div>';
});
}
load();
setInterval(load, 60000);
</script>
</body>
</html>

View File

@ -13,8 +13,13 @@
header {
padding: 13px 20px; background: #1e293b; border-bottom: 1px solid #334155;
display: flex; align-items: center; justify-content: space-between;
flex-wrap: wrap; gap: 10px 16px;
}
header h1 { font-size: 16px; font-weight: 600; color: #f1f5f9; flex: 1 1 200px; min-width: 0; }
.header-links {
display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
flex-shrink: 0;
}
header h1 { font-size: 16px; font-weight: 600; color: #f1f5f9; }
#refresh-info { color: #64748b; font-size: 12px; }
main {
@ -260,16 +265,68 @@
.light-name { font-size:10px; color:#fcd34d; text-align:center;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:64px; }
.lights-empty { color:#475569; font-size:12px; padding:6px 0; }
/* ── Footer (discreet but always visible) ── */
.site-footer {
max-width: 1600px; margin: 0 auto; padding: 12px 20px 22px;
display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 14px 18px;
border-top: 1px solid #1e293b;
opacity: 0.72;
transition: opacity 0.2s;
}
.site-footer:hover { opacity: 1; }
.footer-niepalenie {
color: #94a3b8; text-decoration: none; font-size: 12px; font-weight: 600;
padding: 5px 12px; border-radius: 8px; border: 1px solid #334155; white-space: nowrap;
}
.footer-niepalenie:hover { color: #e2e8f0; border-color: #475569; background: #1e293b; }
.footer-users { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.user-chip {
width: 24px; height: 24px; border-radius: 50%;
background: #1e293b; border: 1px solid #334155;
display: inline-flex; align-items: center; justify-content: center;
font-size: 10px; font-weight: 700; color: #94a3b8;
letter-spacing: 0;
cursor: default;
user-select: none;
}
.user-chip-current { border-color: #2563eb; color: #93c5fd; box-shadow: 0 0 0 1px rgba(37, 99, 235, 0.35); }
.user-chip-approved { color: #94a3b8; }
.user-chip-pending { border-style: dashed; opacity: 0.75; color: #64748b; }
.quick-links-card {
padding: 10px 14px;
display: flex; align-items: center; flex-wrap: wrap; gap: 12px 16px;
border-color: #334155;
background: linear-gradient(180deg, #1e293b 0%, #172033 100%);
}
.quick-links-card .quick-niepal {
color: #93c5fd; font-weight: 600; font-size: 13px; text-decoration: none;
padding: 4px 10px; border-radius: 6px; border: 1px solid #2563eb;
}
.quick-links-card .quick-niepal:hover { background: #1e3a5f; color: #e0f2fe; }
.quick-links-card .quick-lbl { font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: .08em; color: #64748b; margin-right: 4px; }
</style>
</head>
<body>
<header>
<h1>🏠 HA Dashboard — sebson.space</h1>
<span id="refresh-info">Refresh in <span id="countdown">60</span>s</span>
<div class="header-links">
<a href="/niepalenie" style="color:#94a3b8;text-decoration:none;font-weight:600;font-size:12px;padding:4px 10px;border-radius:6px;border:1px solid #334155;" title="Niepalenie">🚭 Niepalenie</a>
<a href="/ansible" style="color:#60a5fa;text-decoration:none;font-weight:600;font-size:12px;background:#1e3a5f;padding:4px 10px;border-radius:6px;border:1px solid #2563eb;">🚀 Ansible</a>
<span id="refresh-info">Refresh in <span id="countdown">60</span>s</span>
</div>
</header>
<main>
<div class="card col-12 quick-links-card" aria-label="Shortcuts">
<span class="quick-lbl">Shortcuts</span>
<a href="/niepalenie" class="quick-niepal">🚭 Niepalenie</a>
{% include 'partials/nav_user_chips.html' %}
</div>
<!-- ═══ ROW 1: Persons / Mazda / Openings / Backup ═══ -->
<div class="card col-3">
<div class="card-title">👤 Who's home</div>
@ -300,22 +357,27 @@
<div class="lights-grid" id="lights-grid"></div>
</div>
<!-- ═══ ROW 3: Temperatures / Power / Crowdsec ═══ -->
<div class="card col-6">
<!-- ═══ ROW 3: Temperatures / Power / Crowdsec / Ansible ═══ -->
<div class="card col-5">
<div class="card-title">🌡 Room temperatures</div>
<div class="temp-grid" id="temps-content"></div>
</div>
<div class="card col-3">
<div class="card-title">⚡ Power consumption</div>
<div class="card col-2">
<div class="card-title">⚡ Power</div>
<div id="power-content"></div>
</div>
<div class="card col-3">
<div class="card col-2">
<div class="card-title">🛡 Crowdsec</div>
<div id="crowdsec-content"></div>
</div>
<div class="card col-3">
<div class="card-title">🚀 Ansible last runs</div>
<div id="ansible-summary-content"></div>
</div>
<!-- ═══ ROW 4: Frigate / Containers ═══ -->
<div class="card col-8">
<div class="card-title">📷 Frigate — last detections</div>
@ -388,6 +450,11 @@
</main>
<footer class="site-footer" aria-label="Shortcuts and accounts">
<a href="/niepalenie" class="footer-niepalenie" title="Niepalenie">🚭 Niepalenie</a>
{% include 'partials/nav_user_chips.html' %}
</footer>
<script>
const PAL = ['#3b82f6','#10b981','#f59e0b','#8b5cf6','#ef4444','#06b6d4','#ec4899','#84cc16','#f97316','#a78bfa'];
let dbSizeChart, autoChart, bwChart;
@ -821,12 +888,35 @@ async function loadZigbee() {
<div class="zb-list">${rows}</div>`;
}
// ── Ansible ──────────────────────────────────────────────────────────────────
async function loadAnsibleSummary() {
const res = await fetch('/api/ansible');
const d = await res.json();
if (d.error) { $('ansible-summary-content').innerHTML = `<div style="color:#475569;font-size:12px">${d.error}</div>`; return; }
if (!d.runs || !d.runs.length) { $('ansible-summary-content').innerHTML = '<div style="color:#475569;font-size:12px">No runs yet</div>'; return; }
$('ansible-summary-content').innerHTML = d.runs.slice(0, 4).map(r => {
const statusColor = r.status === 'success' ? '#34d399' : '#f87171';
return `
<div style="display:flex;justify-content:space-between;align-items:center;padding:5px 0;border-bottom:1px solid #1e293b;font-size:11px;">
<div style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-right:8px;">
<span style="color:${statusColor}"></span>
<span style="color:#cbd5e1;font-weight:500;">${r.playbook}</span>
</div>
<div style="color:#64748b;white-space:nowrap;">${timeAgo(r.ts)}</div>
</div>`;
}).join('') + `
<div style="margin-top:10px;text-align:right;">
<a href="/ansible" style="color:#60a5fa;text-decoration:none;font-size:10px;text-transform:uppercase;font-weight:700;letter-spacing:.05em;">View full history →</a>
</div>`;
}
// ── Load all ──────────────────────────────────────────────────────────────────
async function loadAll() {
await Promise.all([
loadOverview(), loadHome(), loadPower(), loadFrigate(),
loadCrowdsec(), loadContainers(), loadDB(), loadAutomations(), loadBitwarden(),
loadZigbee(),
loadZigbee(), loadAnsibleSummary(),
]);
}

View File

@ -39,30 +39,184 @@
letter-spacing: .08em; color: #64748b; margin-bottom: 12px;
}
/* ── HERO ── */
/* ── HERO — akcent BMW R 1250 GSA (Rallye: lód / czerwień / niebieski + złoty widelec) ── */
.hero {
background: linear-gradient(135deg, #1e293b 0%, #0f2027 50%, #1e293b 100%);
border: 1px solid #334155; border-radius: 10px; padding: 28px 24px;
text-align: center; position: relative; overflow: hidden;
background:
linear-gradient(168deg, rgba(26, 29, 34, 0.97) 0%, #0c0f14 48%, #12161c 100%);
border: 1px solid #2a313a;
border-radius: 12px;
padding: 20px 16px 22px;
text-align: center;
position: relative;
overflow: hidden;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.hero::before {
content: '🚭';
position: absolute; font-size: 120px; opacity: .04;
top: 50%; left: 50%; transform: translate(-50%,-50%);
pointer-events: none; user-select: none;
content: '';
position: absolute;
inset: 0;
background:
linear-gradient(115deg, transparent 40%, rgba(227, 6, 19, 0.06) 50%, transparent 60%),
linear-gradient(-25deg, transparent 55%, rgba(66, 152, 230, 0.05) 65%, transparent 75%);
pointer-events: none;
}
.hero-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .12em; color: #64748b; margin-bottom: 6px; }
.hero-main { font-size: 15px; font-weight: 700; color: #f1f5f9; margin-bottom: 18px; }
.hero-main span { color: #f87171; }
.gsa-strip {
display: flex;
justify-content: center;
gap: 5px;
margin-bottom: 10px;
position: relative;
z-index: 1;
}
.gsa-strip span {
height: 4px;
width: 40px;
border-radius: 2px;
box-shadow: 0 0 12px rgba(0, 0, 0, 0.4);
}
.gsa-strip .gsa-red { background: linear-gradient(90deg, #9a0510, #e30613); }
.gsa-strip .gsa-white { background: linear-gradient(90deg, #cbd5e1, #f8fafc); }
.gsa-strip .gsa-blue { background: linear-gradient(90deg, #154a86, #4298e6); }
/* Statystyki tylko drugiego okresu — bardzo małe */
.track2-mini {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid rgba(66, 152, 230, 0.12);
position: relative;
z-index: 1;
}
.track2-mini-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px 10px;
max-width: 620px;
margin: 0 auto;
}
@media (max-width: 640px) {
.track2-mini-grid { grid-template-columns: repeat(2, 1fr); }
}
.track2-cell {
background: rgba(12, 14, 18, 0.65);
border: 1px solid rgba(66, 152, 230, 0.2);
border-radius: 6px;
padding: 6px 8px;
text-align: center;
}
.track2-k {
display: block;
font-size: 7px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #475569;
margin-bottom: 3px;
line-height: 1.2;
}
.track2-num {
display: block;
font-size: 12px;
font-weight: 700;
color: #e2e8f0;
line-height: 1.15;
font-variant-numeric: tabular-nums;
}
.track2-num.amber { color: #fbbf24; }
.track2-num.green { color: #34d399; }
.track2-num.violet{ color: #a78bfa; }
.track2-sub {
display: block;
font-size: 8px;
color: #64748b;
margin-top: 2px;
}
.hero-title {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.14em;
color: #64748b;
margin-bottom: 5px;
position: relative;
z-index: 1;
}
.hero-main {
font-size: 14px;
font-weight: 700;
color: #e8eef4;
margin-bottom: 14px;
position: relative;
z-index: 1;
}
.hero-main span { color: #e30613; text-shadow: 0 0 20px rgba(227, 6, 19, 0.25); }
.counter-row {
display: flex; justify-content: center; gap: 16px; flex-wrap: wrap;
display: flex;
justify-content: center;
gap: 8px 10px;
flex-wrap: wrap;
position: relative;
z-index: 1;
}
.counter-unit {
background: rgba(15,23,42,.7); border: 1px solid #334155; border-radius: 10px;
padding: 14px 20px; min-width: 90px;
min-width: 54px;
padding: 8px 10px;
border-radius: 8px;
background: linear-gradient(180deg, rgba(34, 39, 48, 0.95), rgba(12, 14, 18, 0.92));
border: 1px solid rgba(201, 162, 39, 0.4);
box-shadow:
0 0 0 1px rgba(227, 6, 19, 0.1),
0 6px 18px rgba(0, 0, 0, 0.45);
transition: border-color 0.25s, box-shadow 0.25s;
}
.counter-val { font-size: 36px; font-weight: 700; color: #f1f5f9; line-height: 1; font-variant-numeric: tabular-nums; }
.counter-lbl { font-size: 10px; color: #64748b; margin-top: 5px; text-transform: uppercase; letter-spacing: .08em; }
.counter-unit:hover {
border-color: rgba(201, 162, 39, 0.65);
box-shadow:
0 0 0 1px rgba(66, 152, 230, 0.15),
0 8px 22px rgba(0, 0, 0, 0.5);
}
.counter-unit--track2 {
border-color: rgba(66, 152, 230, 0.38);
box-shadow:
0 0 0 1px rgba(66, 152, 230, 0.12),
0 6px 18px rgba(0, 0, 0, 0.45);
}
.counter-unit--track2:hover {
border-color: rgba(96, 172, 240, 0.55);
}
.counter-val {
font-size: 22px;
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
color: #eef2f7;
background: linear-gradient(185deg, #ffffff 0%, #c5ced8 42%, #8b98a8 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
@supports not ((-webkit-background-clip: text) or (background-clip: text)) {
.counter-val { color: #e8eef4; }
}
.counter-lbl {
font-size: 8px;
font-weight: 700;
color: #64748b;
margin-top: 4px;
text-transform: uppercase;
letter-spacing: 0.07em;
}
.hero-split {
border-top: 1px solid rgba(66, 152, 230, 0.15);
margin-top: 18px;
padding-top: 18px;
position: relative;
z-index: 1;
}
.hero-split .hero-title { color: #64748b; }
.hero-split .hero-main { font-size: 12px; margin-bottom: 12px; }
.hero-split .hero-main span { color: #60a5fa; text-shadow: 0 0 18px rgba(96, 165, 250, 0.2); }
/* ── STAT CARDS ── */
.stat-big { text-align: center; padding: 8px 0; }
@ -119,6 +273,30 @@
.savings-row:last-child { border-bottom: none; }
.savings-row .sr-label { color: #94a3b8; }
.savings-row .sr-val { color: #fbbf24; font-weight: 600; }
/* Unix (s) — rogi paneli z licznikami */
.counter-unix-panel {
position: relative;
padding: 16px 2px 18px;
margin-top: 2px;
}
.counter-unix-panel--track2 .unix-pin { color: #5c7cab; }
.unix-pin {
position: absolute;
z-index: 2;
font-family: ui-monospace, "Cascadia Code", "Consolas", monospace;
font-size: 7px;
line-height: 1.15;
color: #64748b;
opacity: 0.55;
letter-spacing: 0.02em;
max-width: 40%;
word-break: break-all;
pointer-events: none;
user-select: none;
}
.unix-pin.unix-tl { top: 0; left: 4px; text-align: left; }
.unix-pin.unix-br { bottom: 0; right: 4px; text-align: right; }
</style>
</head>
<body>
@ -132,28 +310,88 @@
<!-- HERO counter -->
<div class="hero col-12">
<div class="gsa-strip" aria-hidden="true">
<span class="gsa-red"></span><span class="gsa-white"></span><span class="gsa-blue"></span>
</div>
<div class="hero-title">Rzuciłem palenie</div>
<div class="hero-main">23 sierpnia 2011 · <span>z przerwą w 2021</span></div>
<div class="counter-row">
<div class="counter-unit">
<div class="counter-val" id="c-lat"></div>
<div class="counter-lbl">lat</div>
<div class="counter-unix-panel" aria-hidden="true">
<span class="unix-pin unix-tl" id="uc-box-r1" title=""><span id="uc-r1"></span></span>
<span class="unix-pin unix-br" id="uc-box-now1" title=""><span id="uc-now1"></span></span>
<div class="counter-row">
<div class="counter-unit">
<div class="counter-val" id="c-lat"></div>
<div class="counter-lbl">lat</div>
</div>
<div class="counter-unit">
<div class="counter-val" id="c-dni"></div>
<div class="counter-lbl">dni</div>
</div>
<div class="counter-unit">
<div class="counter-val" id="c-godz"></div>
<div class="counter-lbl">godzin</div>
</div>
<div class="counter-unit">
<div class="counter-val" id="c-min"></div>
<div class="counter-lbl">minut</div>
</div>
<div class="counter-unit">
<div class="counter-val" id="c-sek"></div>
<div class="counter-lbl">sekund</div>
</div>
</div>
<div class="counter-unit">
<div class="counter-val" id="c-dni"></div>
<div class="counter-lbl">dni</div>
</div>
<div class="hero-split">
<div class="hero-title">Od drugiego rzucenia</div>
<div class="hero-main">25 grudnia 2021 · <span>tylko czas po drugim rzuceniu</span></div>
<div class="counter-unix-panel counter-unix-panel--track2" aria-hidden="true">
<span class="unix-pin unix-tl" id="uc-box-r2" title=""><span id="uc-r2"></span></span>
<span class="unix-pin unix-br" id="uc-box-now2" title=""><span id="uc-now2"></span></span>
<div class="counter-row">
<div class="counter-unit counter-unit--track2">
<div class="counter-val" id="c2-lat"></div>
<div class="counter-lbl">lat</div>
</div>
<div class="counter-unit counter-unit--track2">
<div class="counter-val" id="c2-dni"></div>
<div class="counter-lbl">dni</div>
</div>
<div class="counter-unit counter-unit--track2">
<div class="counter-val" id="c2-godz"></div>
<div class="counter-lbl">godzin</div>
</div>
<div class="counter-unit counter-unit--track2">
<div class="counter-val" id="c2-min"></div>
<div class="counter-lbl">minut</div>
</div>
<div class="counter-unit counter-unit--track2">
<div class="counter-val" id="c2-sek"></div>
<div class="counter-lbl">sekund</div>
</div>
</div>
</div>
<div class="counter-unit">
<div class="counter-val" id="c-godz"></div>
<div class="counter-lbl">godzin</div>
</div>
<div class="counter-unit">
<div class="counter-val" id="c-min"></div>
<div class="counter-lbl">minut</div>
</div>
<div class="counter-unit">
<div class="counter-val" id="c-sek"></div>
<div class="counter-lbl">sekund</div>
<div class="track2-mini" aria-label="Statystyki tylko od drugiego rzucenia">
<div class="track2-mini-grid">
<div class="track2-cell">
<span class="track2-k">Zaoszczędzono</span>
<span class="track2-num amber" id="t2-zaosz"></span>
</div>
<div class="track2-cell">
<span class="track2-k">Nie wypalono</span>
<span class="track2-num green" id="t2-pap"></span>
<span class="track2-sub" id="t2-pacz"></span>
</div>
<div class="track2-cell">
<span class="track2-k">Średnio / tydzień</span>
<span class="track2-num" id="t2-tydz"></span>
</div>
<div class="track2-cell">
<span class="track2-k">Następny kamień</span>
<span class="track2-num violet" id="t2-jub"></span>
<span class="track2-sub" id="t2-jubza"></span>
</div>
</div>
</div>
</div>
</div>
@ -433,8 +671,16 @@ const _TODAY_START = (function() {
const d = new Date(); d.setHours(0,0,0,0); return d;
})();
const _SAVINGS_TO_TODAY = integrateSavings(RZUCONE_DATE, _TODAY_START);
const _SAV2_TO_TODAY = integrateSegment(NAWROT_KONIEC, _TODAY_START);
const _COST_PER_SEC_NOW = (DZIENNIE / W_PACZCE) * CURRENT_PRICE / SEKUND_DOBY;
function currentSavingsTrack2(now) {
if (now < NAWROT_KONIEC) return 0;
const ts = Math.max(_TODAY_START.getTime(), NAWROT_KONIEC.getTime());
const secs = (now.getTime() - ts) / 1000;
return _SAV2_TO_TODAY + Math.max(0, secs) * _COST_PER_SEC_NOW;
}
function currentSavings() {
const now = new Date();
// Dzisiaj w nawrocie? (historycznie niemożliwe, ale dla poprawności)
@ -612,6 +858,14 @@ function jubInfo(totalDays) {
function update() {
const now = new Date();
const tsNow = Math.floor(now.getTime() / 1000);
const nowIso = now.toISOString();
const nowTitle = 'Teraz: ' + nowIso + ' → ' + tsNow;
document.getElementById('uc-now1').textContent = String(tsNow);
document.getElementById('uc-now2').textContent = String(tsNow);
document.getElementById('uc-box-now1').title = nowTitle;
document.getElementById('uc-box-now2').title = nowTitle;
const st = calcStats(now);
const d = st.d;
const s = st.s;
@ -624,6 +878,34 @@ function update() {
document.getElementById('c-min').textContent = String(dc.min).padStart(2, '0');
document.getElementById('c-sek').textContent = String(dc.sek).padStart(2, '0');
// Drugie rzucenie — ten sam układ co główny licznik, tylko czas od końca nawrotu (25.12.2021)
const s2 = Math.max(0, (now - NAWROT_KONIEC) / 1000);
const dc2 = decompose(s2);
document.getElementById('c2-lat').textContent = dc2.lat;
document.getElementById('c2-dni').textContent = String(dc2.dni).padStart(3, ' ');
document.getElementById('c2-godz').textContent = String(dc2.godz).padStart(2, '0');
document.getElementById('c2-min').textContent = String(dc2.min).padStart(2, '0');
document.getElementById('c2-sek').textContent = String(dc2.sek).padStart(2, '0');
// Mini statystyki wyłącznie za drugi okres (od końca nawrotu)
const d2 = st.o2 / SEKUND_DOBY;
const sav2 = currentSavingsTrack2(now);
const pap2 = Math.floor(DZIENNIE * d2);
document.getElementById('t2-zaosz').textContent = fmtPln(sav2);
document.getElementById('t2-pap').textContent = fmt(pap2);
const pacz2 = Math.floor(pap2 / W_PACZCE);
document.getElementById('t2-pacz').textContent = pacz2 > 0 ? '≈ ' + fmt(pacz2) + ' paczek' : '';
const tyg2 = st.o2 / (7 * SEKUND_DOBY);
document.getElementById('t2-tydz').textContent =
tyg2 >= 1 ? fmtPln(sav2 / tyg2) : '—';
const j2 = jubInfo(d2);
const lastM = MILESTONES[MILESTONES.length - 1][0];
document.getElementById('t2-jub').textContent = j2.label;
let jubza = '';
if (j2.remDays > 0) jubza = 'za ' + Math.ceil(j2.remDays) + ' dni';
else if (d2 >= lastM) jubza = 'kamienie ✓';
document.getElementById('t2-jubza').textContent = jubza;
const zaoszcz = currentSavings();
const nieWypalil = Math.floor(DZIENNIE * d);
@ -696,6 +978,16 @@ async function fetchCzkRate() {
}
// ── Init
(function initUnixCorners() {
const ts1 = Math.floor(RZUCONE_DATE.getTime() / 1000);
const ts2 = Math.floor(NAWROT_KONIEC.getTime() / 1000);
document.getElementById('uc-r1').textContent = String(ts1);
document.getElementById('uc-r2').textContent = String(ts2);
const iso1 = RZUCONE_DATE.toISOString();
const iso2 = NAWROT_KONIEC.toISOString();
document.getElementById('uc-box-r1').title = 'Pierwsze rzucenie: ' + iso1 + ' → ' + ts1;
document.getElementById('uc-box-r2').title = 'Drugie rzucenie: ' + iso2 + ' → ' + ts2;
})();
update();
buildSavingsChart();
fetchCzkRate();

View File

@ -0,0 +1,7 @@
{% if nav_user_chips %}
<span class="footer-users" aria-label="Dashboard accounts">
{% for chip in nav_user_chips %}
<span class="user-chip user-chip-{{ chip.role }}" title="{% if chip.role == 'pending' %}{{ chip.name }} (pending approval){% elif chip.role == 'current' %}{{ chip.name }} (signed in){% else %}{{ chip.name }}{% endif %}">{{ (chip.name[0] if chip.name else '?')|upper }}</span>
{% endfor %}
</span>
{% endif %}

View File

@ -10,7 +10,7 @@ Broker MQTT bedacy magistrala komunikacyjna dla calego systemu smart home - lacz
## Konfiguracja
- Obraz: `eclipse-mosquitto`
- Obraz: `eclipse-mosquitto:2` (seria 2.x; unikamy niejawnego `:latest`)
- Siec: `network_mode: host`
- Plik konfiguracyjny: `./mosquitto/config/mosquitto.conf`
- Hasla: `./mosquitto/config/mosquitto.passwd` (format bcrypt)
@ -19,7 +19,9 @@ Broker MQTT bedacy magistrala komunikacyjna dla calego systemu smart home - lacz
Kluczowe ustawienia:
- `allow_anonymous false` - wymagane uwierzytelnienie
- `persistence false` - brak persystencji wiadomosci (wystarczajace dla QoS 0)
- `persistence false` - broker nie zapisuje retajnowanych wiadomości ani stanu na dysku (po restarcie znikają retained; dla typowego HA + czujników często OK)
- `persistence_location` wskazuje na volume `/mosquitto/data` (gdyby włączyć `persistence true`)
- Opcjonalne fragmenty konfiguracji: katalog `config/conf.d/*.conf` (ładowane przez `include_dir`)
- `persistent_client_expiration 3m` - klienci offline usuwani po 3 minutach
- `max_inflight_messages 40`
- Logowane tylko bledy, ostrzezenia i powiadomienia (nie kazde polaczenie)

View File

@ -1,4 +1,5 @@
# Place your local configuration in /config/conf.d/
# Place your local configuration in /mosquitto/config/conf.d/
include_dir /mosquitto/config/conf.d/
sys_interval 10
user mosquitto
max_inflight_messages 40
@ -11,7 +12,7 @@ autosave_on_changes false
persistence false
persistence_file mosquitto.db
persistence_location /config/data/
persistence_location /mosquitto/data/
# Port to use for the default listener.
listener 1883

1
pa Normal file
View File

@ -0,0 +1 @@
du

77
postgresql-ssl/server.crt Normal file
View File

@ -0,0 +1,77 @@
Certificate:
Data:
Version: 3 (0x2)
Serial Number:
25:3f:e2:43:15:b6:48:b4:41:6b:ac:df:96:01:fd:bc:79:28:51:45
Signature Algorithm: sha256WithRSAEncryption
Issuer: CN = postgresql18
Validity
Not Before: Apr 10 21:59:52 2026 GMT
Not After : Apr 7 21:59:52 2036 GMT
Subject: CN = postgresql18
Subject Public Key Info:
Public Key Algorithm: rsaEncryption
Public-Key: (2048 bit)
Modulus:
00:bf:2a:65:36:f6:81:b7:ff:5a:32:6a:b3:0e:3f:
47:73:18:80:9e:0d:db:8c:53:e8:54:1a:45:a9:a5:
73:d2:2b:e2:3c:97:90:43:0e:41:18:42:b7:b2:19:
b4:da:9e:68:93:b0:35:69:1f:0d:ea:82:5e:bf:b1:
b3:de:4a:7f:4e:51:88:1b:12:43:97:01:04:4a:76:
0b:a2:69:30:1c:c9:cc:38:9e:f6:4a:95:cc:85:9a:
49:34:5f:c9:34:7e:d7:85:90:f9:26:37:e5:0b:e8:
03:1a:21:c8:33:e6:81:dd:43:61:12:5c:27:da:b6:
fa:26:24:ad:49:8b:a9:9c:a0:b9:6a:e9:51:f8:22:
00:74:24:5b:56:11:77:08:f3:e3:56:d9:7c:0a:ac:
ee:72:f6:2e:b7:7e:42:59:fa:02:18:f1:6a:1d:8a:
52:f8:51:d3:22:c5:5e:81:2a:f9:de:ec:98:82:fd:
b3:1a:7f:fb:04:67:9a:95:75:f8:5d:af:41:ff:6a:
50:9b:9b:d4:ac:99:f6:0d:0d:83:af:34:26:be:e8:
cc:69:09:19:f4:39:7c:06:6e:ee:53:20:15:7e:eb:
5d:6e:c7:39:97:f5:7a:99:99:c3:70:98:ce:6b:c5:
49:0b:d1:d7:dc:3c:00:30:64:6a:31:5a:9c:9b:51:
30:cf
Exponent: 65537 (0x10001)
X509v3 extensions:
X509v3 Subject Key Identifier:
90:91:2B:C8:3C:DD:A0:A3:9C:45:D8:ED:08:9E:28:E1:D2:CC:43:AB
X509v3 Authority Key Identifier:
90:91:2B:C8:3C:DD:A0:A3:9C:45:D8:ED:08:9E:28:E1:D2:CC:43:AB
X509v3 Basic Constraints: critical
CA:TRUE
Signature Algorithm: sha256WithRSAEncryption
Signature Value:
61:74:11:ff:d8:62:d3:81:54:a3:58:d4:40:57:9c:15:26:72:
32:b4:25:01:98:49:14:0f:65:4d:a9:e6:0e:88:6d:d1:5b:2d:
a8:04:4d:11:c7:00:d8:20:bd:30:11:73:33:78:1d:e0:e0:98:
f8:18:d4:b4:c8:90:ae:9c:cf:70:d0:86:bc:13:2d:61:bb:42:
89:e5:95:ca:fd:0b:7b:00:73:1a:f9:ab:84:14:c8:07:9e:dd:
9c:aa:70:a7:25:34:27:0e:5e:5a:d6:51:38:3c:66:ae:00:9a:
2c:2e:f2:be:14:1d:99:99:8c:c8:98:47:94:0f:72:5e:44:b6:
a4:c6:8d:c2:7e:8b:6a:ee:b3:87:73:64:69:ef:78:47:d9:df:
1a:fb:8e:0e:a6:60:13:70:46:14:0a:d0:28:69:89:88:a9:77:
6e:1d:44:b1:d8:b6:f2:40:62:1a:c8:c1:28:af:30:22:52:87:
c2:b6:14:1e:3e:39:bf:e9:25:f5:16:72:b4:db:7b:62:a2:e0:
d8:b0:bc:99:bb:fa:8d:8f:08:f9:fa:20:c7:c0:c8:37:b9:07:
af:ca:63:04:f6:84:91:b5:82:04:14:99:a9:05:c6:a8:35:72:
45:76:72:62:e4:ab:7c:e4:67:c5:d9:db:68:60:fe:ca:a5:a8:
a7:b0:8b:d1
-----BEGIN CERTIFICATE-----
MIIDDzCCAfegAwIBAgIUJT/iQxW2SLRBa6zflgH9vHkoUUUwDQYJKoZIhvcNAQEL
BQAwFzEVMBMGA1UEAwwMcG9zdGdyZXNxbDE4MB4XDTI2MDQxMDIxNTk1MloXDTM2
MDQwNzIxNTk1MlowFzEVMBMGA1UEAwwMcG9zdGdyZXNxbDE4MIIBIjANBgkqhkiG
9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvyplNvaBt/9aMmqzDj9HcxiAng3bjFPoVBpF
qaVz0iviPJeQQw5BGEK3shm02p5ok7A1aR8N6oJev7Gz3kp/TlGIGxJDlwEESnYL
omkwHMnMOJ72SpXMhZpJNF/JNH7XhZD5JjflC+gDGiHIM+aB3UNhElwn2rb6JiSt
SYupnKC5aulR+CIAdCRbVhF3CPPjVtl8CqzucvYut35CWfoCGPFqHYpS+FHTIsVe
gSr53uyYgv2zGn/7BGealXX4Xa9B/2pQm5vUrJn2DQ2DrzQmvujMaQkZ9Dl8Bm7u
UyAVfutdbsc5l/V6mZnDcJjOa8VJC9HX3DwAMGRqMVqcm1EwzwIDAQABo1MwUTAd
BgNVHQ4EFgQUkJEryDzdoKOcRdjtCJ4o4dLMQ6swHwYDVR0jBBgwFoAUkJEryDzd
oKOcRdjtCJ4o4dLMQ6swDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC
AQEAYXQR/9hi04FUo1jUQFecFSZyMrQlAZhJFA9lTanmDoht0VstqARNEccA2CC9
MBFzM3gd4OCY+BjUtMiQrpzPcNCGvBMtYbtCieWVyv0LewBzGvmrhBTIB57dnKpw
pyU0Jw5eWtZRODxmrgCaLC7yvhQdmZmMyJhHlA9yXkS2pMaNwn6Lau6zh3Nkae94
R9nfGvuODqZgE3BGFArQKGmJiKl3bh1Esdi28kBiGsjBKK8wIlKHwrYUHj45v+kl
9RZytNt7YqLg2LC8mbv6jY8I+fogx8DIN7kHr8pjBPaEkbWCBBSZqQXGqDVyRXZy
YuSrfORnxdnbaGD+yqWop7CL0Q==
-----END CERTIFICATE-----

View File

@ -7,9 +7,8 @@ type: public
encoding: UTF-8
followredirect: true
links:
- https://torrentqq414.com/
- https://torrentqq415.com/
legacylinks:
- https://torrentqq399.com/
- https://torrentqq400.com/
- https://torrentqq401.com/
- https://torrentqq402.com/
@ -25,6 +24,7 @@ legacylinks:
- https://torrentegg87.com/
- https://torrentqq412.com/
- https://torrentqq413.com/
- https://torrentqq414.com/
caps:
categorymappings:

View File

@ -48,6 +48,7 @@ caps:
MiniVideo: Other # MiniVideo
补充音轨: Audio # Supplemental audio tracks
iPhone/iPad视频: PC/Mobile-Other # IPhone / iPad video
动漫原盘: Movies/Other # Anime BluRay Original disc
# 单机游戏
PC: PC/Games # Games - PC
MAC: PC/Mac # Games - MAC
@ -148,7 +149,7 @@ search:
inputs:
c: M
- path: browse.php
categories: [1000, 1010, 1020, 1030, 1040, 1050, 1060, 1070, 1080, 1090, 1100, 1110, 1120, 1130, 1140, 1150, 1160, 1170, 1180, 4000, 4010, 4020, 4030, 4040, 4050, 4060, 4070, 7000, 7010, 7020, 7030, 7040, 7050, 7060]
categories: [1000, 1010, 1020, 1030, 1040, 1050, 1080, 1090, 1120, 1130, 1180, 4000, 4030, 4040, 4050, 4060, 7000, 7020]
inputs:
c: G
inputs:

View File

@ -50,6 +50,7 @@ caps:
MiniVideo: Other # MiniVideo
补充音轨: Audio # Supplemental audio tracks
iPhone/iPad视频: PC/Mobile-Other # IPhone / iPad video
动漫原盘: Movies/Other # Anime BluRay Original disc
# 单机游戏
PC: PC/Games # Games - PC
MAC: PC/Mac # Games - MAC
@ -113,7 +114,7 @@ search:
inputs:
c: M
- path: browse.php
categories: [1000, 1010, 1020, 1030, 1040, 1050, 1060, 1070, 1080, 1090, 1100, 1110, 1120, 1130, 1140, 1150, 1160, 1170, 1180, 4000, 4010, 4020, 4030, 4040, 4050, 4060, 4070, 7000, 7010, 7020, 7030, 7040, 7050, 7060]
categories: [1000, 1010, 1020, 1030, 1040, 1050, 1080, 1090, 1120, 1130, 1180, 4000, 4030, 4040, 4050, 4060, 7000, 7020]
inputs:
c: G
inputs: