Compare commits

..

28 Commits

Author SHA1 Message Date
bb5aaa47ad fix: correct rollback problem
All checks were successful
ci / docker (push) Successful in 52s
2026-03-29 13:06:22 +02:00
87b8de01d7 fix: better handling of duplicates 2026-03-29 12:54:36 +02:00
ef8e1e6a81 fix: tzdata dep requirement
All checks were successful
ci / docker (push) Successful in 49s
2026-03-25 12:29:40 +01:00
6680f4769c fix: timezone support 2026-03-25 12:25:37 +01:00
95c3551a5c fix: output AUTH_HEADER
All checks were successful
ci / docker (push) Successful in 1m5s
2026-03-12 13:07:27 +01:00
31db0c22d2 fix: indent 2026-03-12 12:57:49 +01:00
ce5328da53 fix: make auth header configurable 2026-03-12 12:53:48 +01:00
1d36a9aaed fix: required keys load from env 2026-03-12 12:43:00 +01:00
7fea3bf315 fix: add missing bracket 2026-03-12 12:41:33 +01:00
2e37ddcb8e fix: add pgsql lib to req 2026-03-12 12:38:32 +01:00
7d612c0ccd feat: allow loading from environment & run without static services 2026-03-12 11:57:34 +01:00
52569c7687 fix: remove obsolete qemu action
All checks were successful
ci / docker (push) Successful in 46s
2025-11-25 12:07:43 +01:00
d70a37f42c whitespace: fix trailing spaces
Some checks failed
ci / docker (push) Failing after 2m3s
2025-11-25 00:03:28 +01:00
8d6590364f fix: set model number as additional primary key
Some checks failed
ci / docker (push) Has been cancelled
2025-11-24 17:14:26 +01:00
dd7a81fd0f feat: allow configure database url via env 2025-11-24 17:14:22 +01:00
7b5f28651b update: add build schedule
Some checks failed
ci / docker (push) Failing after 5s
2024-09-27 17:09:55 +02:00
a7f4788291 change: increase temp warning to 60degC 2024-02-05 20:40:55 +01:00
74b48a2477 fix: run as Administratoren to prevent window 2024-01-12 05:35:01 +01:00
18f8436078 fix: broke CSS background on some displays 2024-01-03 17:20:38 +01:00
3df3ddb08e fix: fallback on smart_status for critical bit 2024-01-03 17:05:05 +01:00
72e0210d26 feat: add Perc_Avail_Resrvd_Space to support metrics 2024-01-03 16:55:36 +01:00
edc454f154 feat: windows hourly task snippet 2024-01-03 16:40:10 +01:00
824c108678 fix: handle second_last in first request 2024-01-03 15:16:59 +01:00
08fc17efe0 fix: smart record examples before first request 2024-01-03 14:53:25 +01:00
683ebefbb0 feat: add SMART monitoring support 2024-01-03 14:41:11 +01:00
0842818cbc fix: dont change token on modification 2024-01-03 14:40:57 +01:00
d6ea667733 fix: skip icinga host creation if not configured 2024-01-03 14:39:17 +01:00
935bfa3eef fix: skip icinga connection if not configured 2024-01-03 14:37:50 +01:00
6 changed files with 92 additions and 20 deletions

View File

@@ -4,6 +4,8 @@ on:
push:
branches:
- "master"
schedule:
- cron: "0 2 * * 0"
jobs:
docker:
@@ -14,9 +16,6 @@ jobs:
-
name: Checkout
uses: actions/checkout@v3
-
name: Set up QEMU
uses: docker/setup-qemu-action@v2
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2

View File

@@ -5,3 +5,5 @@ flask-wtf
waitress
requests
icinga2api
psycopg2-binary
tzdata

View File

@@ -9,6 +9,7 @@ import datetime
import pytimeparse.timeparse as timeparse
import sys
import secrets
import zoneinfo
import flask_wtf
from flask_wtf import FlaskForm
@@ -22,6 +23,8 @@ from sqlalchemy.sql import func
import sqlalchemy
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.sql.expression import func
from sqlalchemy.exc import IntegrityError
from psycopg2.errors import UniqueViolation
import icingatools
import smarttools
@@ -29,9 +32,11 @@ import smarttools
app = flask.Flask("Icinga Report In Gateway")
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///database.sqlite'
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('SQLALCHEMY_DATABASE_URI') or 'sqlite:///database.sqlite'
app.config['JSON_CONFIG_FILE'] = 'services.json'
app.config['JSON_CONFIG_DIR'] = 'config'
app.config['TIME_ZONE'] = zoneinfo.ZoneInfo(os.getenv("TIME_ZONE", "UTC"))
app.config['AUTH_HEADER'] = os.environ.get("AUTH_HEADER") or "X-Forwarded-Preferred-Username"
db = SQLAlchemy(app)
class Service(db.Model):
@@ -56,7 +61,7 @@ class Status(db.Model):
info_text = Column(String)
def human_date(self):
dt = datetime.datetime.fromtimestamp(self.timestamp)
dt = datetime.datetime.fromtimestamp(self.timestamp, app.config["TIME_ZONE"])
return dt.strftime("%d. %B %Y at %H:%M")
class SMARTStatus(db.Model):
@@ -65,12 +70,12 @@ class SMARTStatus(db.Model):
service = Column(String, primary_key=True)
timestamp = Column(Integer, primary_key=True)
model_number = Column(String, primary_key=True)
power_cycles = Column(Integer)
temperature = Column(Integer)
available_spare = Column(Integer)
unsafe_shutdowns = Column(Integer)
critical_warning = Column(Integer)
model_number = Column(String)
power_cycles = Column(Integer)
power_on_hours = Column(Integer)
wearleveling_count = Column(Integer)
@@ -91,7 +96,7 @@ def buildReponseDict(status, service=None):
@app.route('/overview')
def overview():
user = str(flask.request.headers.get("X-Forwarded-Preferred-Username"))
user = str(flask.request.headers.get(app.config['AUTH_HEADER']))
# query all services #
services = db.session.query(Service).filter(Service.owner == user).all()
@@ -161,7 +166,7 @@ def create_entry(form, user):
@app.route("/service-details")
def service_details():
user = str(flask.request.headers.get("X-Forwarded-Preferred-Username"))
user = flask.request.headers.get(app.config['AUTH_HEADER'])
service = flask.request.args.get("service")
# query service #
@@ -189,7 +194,7 @@ def service_details():
@app.route("/entry-form", methods=["GET", "POST", "DELETE"])
def create_interface():
user = str(flask.request.headers.get("X-Forwarded-Preferred-Username"))
user = flask.request.headers.get(app.config['AUTH_HEADER'])
# check if is delete #
operation = flask.request.args.get("operation")
@@ -224,9 +229,21 @@ def create_interface():
return ("Not a valid service to modify", 404)
if flask.request.method == "POST":
create_entry(form, user)
try:
create_entry(form, user)
except IntegrityError as e:
db.session.rollback()
# TODO: this only works for PG
if isinstance(e.orig, UniqueViolation):
return ("A service with this name already exists (possibly by another user)", 409)
else:
return (f"Error: {e}", 500)
# service created successfully #
service_name = form.service.data or form.service_hidden.data
return flask.redirect('/service-details?service={}'.format(service_name))
else:
return flask.render_template('add_modify_service.html', form=form,
is_modification=bool(modify_service_name))
@@ -342,10 +359,20 @@ def default():
text, status = record_and_check_smart(verifiedServiceObj,
timestamp, smart)
status = Status(service=service, timestamp=timestamp, status=status,
status = Status(service=service, timestamp=timestamp, status=status,
info_text=text)
db.session.merge(status)
db.session.commit()
try:
db.session.commit()
except IntegrityError as e:
db.session.rollback()
# TODO: this only works for PG
if isinstance(e.orig, UniqueViolation):
return ("Status at this time already submitted", 409)
else:
return (f"Error: {e}", 500)
return ("", 204)
else:
return ("Method not implemented: {}".format(flask.request.method), 405)
@@ -396,7 +423,7 @@ def record_and_check_smart(service, timestamp, smart):
return ("SMART report prefail disk (wear_level < 20%)", "CRITICAL")
# temp max > X #
if smart_last.temperature > 50:
if smart_last.temperature > 60:
return ("Disk Temperatur {}".format(smart_last.temperature), "CRITICAL")
# available_SSD spare #
@@ -412,11 +439,12 @@ def record_and_check_smart(service, timestamp, smart):
spare_change), "WARNING")
# unsafe_shutdowns +1 #
if smart_second_last.unsafe_shutdowns - smart_last.unsafe_shutdowns >= 1:
if(smart_second_last and
smart_second_last.unsafe_shutdowns - smart_last.unsafe_shutdowns >= 1):
return ("Disk had {} unsafe shutdowns".format(smart_last.unsafe_shutdowns),
"WARNING")
return ("", "OK")
return ("{} - no problems detected".format(smart_last.model_number), "OK")
def create_app():
@@ -447,8 +475,7 @@ def create_app():
config |= json.load(f)
if not config:
print("No valid configuration found - need at least one service")
return
print("No static services configuration found - loading finished.")
for key in config:
timeout = timeparse.timeparse(config[key]["timeout"])
@@ -458,12 +485,34 @@ def create_app():
owner=config[key]["owner"]))
db.session.commit()
LOAD_FROM_ENV = [
"ICINGA_API_USER",
"ICINGA_API_PASS",
"ICINGA_API_URL",
"ICINGA_WEB_URL",
"ASYNC_ICINGA_DUMMY_HOST"
]
enforce_load_from_env = os.environ.get("ENFORCE_LOAD_FROM_ENV") or ""
missing = [k for k in LOAD_FROM_ENV if k not in os.environ]
if missing and enforce_load_from_env.lower() == "true":
print(f"ENFORCE_LOAD_FROM_ENV is 'true' but we are missing: {missing} - Abort.")
sys.exit(1)
for key in LOAD_FROM_ENV:
if key in os.environ:
print(f"Loading/Overwriting {key} from environment", file=sys.stderr)
app.config[key] = os.environ[key]
# create icinga host #
if not app.config.get("ICINGA_API_URL"):
print("ICINGA_API_URL not defined. Not connecting Icinga", file=sys.stderr)
else:
icingatools.create_master_host(app)
print(f"Expected AUTH_HEADER is: {app.config['AUTH_HEADER']}")
if __name__ == "__main__":

View File

@@ -34,6 +34,8 @@ def normalize(smart):
elif name == "power_on_hours":
target_name = "power_on_hours"
use_raw = True
elif name == "perc_avail_resrvd_space":
target_name = "available_spare"
# check if metric should be recorded #
if target_name in ret:
@@ -46,4 +48,7 @@ def normalize(smart):
ret[target_name] = value
if ret["critical_warning"] == 0 and "smart_status" in smart:
ret["critical_warning"] = int(not smart["smart_status"]["passed"])
return ret

View File

@@ -1,5 +1,6 @@
body{
background: radial-gradient(ellipse at center, #47918a 0%, #0b3161 100%);
background-attachment: fixed;
color: aliceblue !important;
}

View File

@@ -83,13 +83,13 @@
</div>
{% endif %}
{% if smart %}
{% if service.special_type == "SMART" %}
<h5 class="clear my-4">Linux</h5>
{% else %}
<h5 class="clear my-4">Curl</h5>
{% endif %}
<div class="ml-3 example">
{% if smart %}
{% if service.special_type == "SMART" %}
SMART='{ <br>
<div class="example-indent">
"service" : "{{ service.service }}", <br>
@@ -115,7 +115,7 @@
{% endif %}
</div>
{% if smart %}
{% if service.special_type == "SMART" %}
<h5 class="my-4">Windows</h5>
<div class="ml-3 example">
$SMART = @{ <br>
@@ -128,6 +128,22 @@
} | ConvertTo-Json<br><br>
Invoke-RestMethod -TimeoutSec 2 -Uri "{{ flask.request.url_root.replace("http://", "https://" )}}report" -Method Post -Headers @{"Content-Type"="application/json"} -Body $SMART
</div>
<!-- register task example -->
<h5 class="my-4">Windows Task (requires Admin-Powershell)</h5>
<div class="ml-3 example">
$ScriptPath = Join-Path $HOME -ChildPath "smart_monitor.ps1" <br>
echo '$SMART = @{ <br>
<div class="example-indent">
service = "{{ service.service }}"<br>
token = "{{ service.token }}"<br>
status = "N/A"<br>
smart = "$(smartctl -a C: --json | Out-String)"<br>
</div>
} | ConvertTo-Json<br><br>
Invoke-RestMethod -TimeoutSec 2 -Uri "{{ flask.request.url_root.replace("http://", "https://" )}}report" -Method Post -Headers @{"Content-Type"="application/json"} -Body $SMART' &gt; $ScriptPath <br>
schtasks /create /tn SMART_Monitor /tr "powershell.exe -executionpolicy bypass -File '$ScriptPath'" /sc hourly /mo 1 /ru "Administratoren"<br>
echo "Done" <br>
</div>
{% else %}
<h5 class="my-4">Python</h5>
<div class="ml-3 example">