mirror of
https://github.com/FAUSheppy/icinga-webhook-gateway
synced 2025-12-08 00:11:40 +01:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d5d964ea8e | |||
| 8d4882d7c4 | |||
| 6ca2278838 | |||
| 8884e924e8 | |||
| 9eef4b1353 | |||
| ebb644fdf2 | |||
| c487c769b0 | |||
| ff2d8296f3 | |||
| ad08f69df1 | |||
| 1860cba273 | |||
| 95db2f4b9f | |||
| 5439f46bd3 | |||
| 163854d7bd | |||
| c1addbf9fb | |||
| 3b89f534dd | |||
| 6c1afa82ff | |||
| cfab1e499c | |||
| fed1eab6d0 | |||
| 8be2e3d054 | |||
| c93ab82119 | |||
| 5b49bb2b37 | |||
| c4ed500961 | |||
| 9ac05997d3 | |||
| fb1356bbb4 | |||
| ef6b3fa5fb | |||
| 0fb170c97e | |||
| 3ba958f6e5 | |||
| 2d221a38c6 | |||
| 696f190667 | |||
| 5449fd1437 | |||
| e086a221b7 | |||
| 7ea5578132 | |||
| bb1c2a25e3 | |||
| a726032cf6 | |||
| 8c39628a51 | |||
| 876e3101c0 | |||
| 0d6e2aef7b | |||
| b879bd0a84 | |||
| 4653b5b425 | |||
| fcb410e947 |
2
.github/workflows/main.yaml
vendored
2
.github/workflows/main.yaml
vendored
@@ -34,4 +34,4 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
push: true
|
push: true
|
||||||
tags: "${{ secrets.REGISTRY }}/athq/async-icinga:latest"
|
tags: "${{ secrets.REGISTRY }}/atlantishq/async-icinga:latest"
|
||||||
|
|||||||
17
Dockerfile
17
Dockerfile
@@ -1,25 +1,20 @@
|
|||||||
FROM python:3.9-slim-buster
|
FROM alpine
|
||||||
|
RUN apk add --no-cache py3-pip
|
||||||
RUN apt update
|
RUN apk add --no-cache curl
|
||||||
RUN apt install python3-pip -y
|
|
||||||
RUN python3 -m pip install --upgrade pip
|
|
||||||
RUN apt install curl -y
|
|
||||||
RUN apt autoremove -y
|
|
||||||
RUN apt clean
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN python3 -m pip install waitress
|
RUN python3 -m pip install --no-cache-dir --break-system-packages waitress
|
||||||
|
|
||||||
COPY req.txt .
|
COPY req.txt .
|
||||||
RUN python3 -m pip install --no-cache-dir -r req.txt
|
RUN python3 -m pip install --no-cache-dir --break-system-packages -r req.txt
|
||||||
|
|
||||||
# precreate database directory for mount (will otherwise be created at before_first_request)
|
# precreate database directory for mount (will otherwise be created at before_first_request)
|
||||||
COPY ./ .
|
COPY ./ .
|
||||||
RUN mkdir /app/instance/
|
RUN mkdir /app/instance/
|
||||||
|
|
||||||
HEALTHCHECK --interval=1m --timeout=5s --start-period=10s \
|
HEALTHCHECK --interval=1m --timeout=5s --start-period=10s \
|
||||||
CMD /usr/bin/curl --fail http://localhost:5000/ || exit 1
|
CMD /usr/bin/curl --fail http://localhost:5000/alive || exit 1
|
||||||
EXPOSE 5000/tcp
|
EXPOSE 5000/tcp
|
||||||
|
|
||||||
ENTRYPOINT ["waitress-serve"]
|
ENTRYPOINT ["waitress-serve"]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
Copyright 2021 Yannik Schmidt
|
Copyright 2023 Yannik Schmidt
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
urlBase = "{proto}://{host}:{port}/?service={service}"
|
urlBase = "{proto}://{host}:{port}/report?service={service}"
|
||||||
if not args.port:
|
if not args.port:
|
||||||
urlBase = "{proto}://{host}/?service={service}"
|
urlBase = "{proto}://{host}/report?service={service}"
|
||||||
|
|
||||||
url = urlBase.format(proto=args.protocol, host=args.host,
|
url = urlBase.format(proto=args.protocol, host=args.host,
|
||||||
port=args.port, service=args.service)
|
port=args.port, service=args.service)
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ def _build_service_name(user, async_service_name):
|
|||||||
|
|
||||||
def create_service(user, async_service_name, app):
|
def create_service(user, async_service_name, app):
|
||||||
|
|
||||||
|
if not app.config.get("ICINGA_API_URL"):
|
||||||
|
return
|
||||||
|
|
||||||
client = _create_client(app)
|
client = _create_client(app)
|
||||||
name = _build_service_name(user, async_service_name)
|
name = _build_service_name(user, async_service_name)
|
||||||
host_name = app.config["ASYNC_ICINGA_DUMMY_HOST"]
|
host_name = app.config["ASYNC_ICINGA_DUMMY_HOST"]
|
||||||
@@ -54,7 +57,8 @@ def create_service(user, async_service_name, app):
|
|||||||
"vars" : {
|
"vars" : {
|
||||||
"host" : "async-icinga.atlantishq.de",
|
"host" : "async-icinga.atlantishq.de",
|
||||||
"service_name" : async_service_name,
|
"service_name" : async_service_name,
|
||||||
"protocol" : "https"
|
"protocol" : "https",
|
||||||
|
"owner" : user
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,6 +70,9 @@ def create_service(user, async_service_name, app):
|
|||||||
|
|
||||||
def delete_service(user, async_service_name, app):
|
def delete_service(user, async_service_name, app):
|
||||||
|
|
||||||
|
if not app.config.get("ICINGA_API_URL"):
|
||||||
|
return
|
||||||
|
|
||||||
client = _create_client(app)
|
client = _create_client(app)
|
||||||
name = _build_service_name(user, async_service_name)
|
name = _build_service_name(user, async_service_name)
|
||||||
host_name = app.config["ASYNC_ICINGA_DUMMY_HOST"]
|
host_name = app.config["ASYNC_ICINGA_DUMMY_HOST"]
|
||||||
@@ -73,11 +80,24 @@ def delete_service(user, async_service_name, app):
|
|||||||
|
|
||||||
client.objects.delete("Service", service_api_helper_name)
|
client.objects.delete("Service", service_api_helper_name)
|
||||||
|
|
||||||
def build_icinga_link_for_service(user, service_name, app):
|
def build_icinga_link_for_service(user, service_name, static_configured, app):
|
||||||
|
|
||||||
name = _build_service_name(user, service_name)
|
name = _build_service_name(user, service_name)
|
||||||
url_fmt = "{base}/icingaweb2/dashboard/#!/icingaweb2/monitoring/service/show?host={host}&service={service}"
|
url_fmt = "{base}/icingaweb2/dashboard/#!/icingaweb2/monitoring/service/show?host={host}&service={service}"
|
||||||
return url_fmt.format(base=app.config["ICINGA_WEB_URL"],
|
|
||||||
host=app.config["ASYNC_ICINGA_DUMMY_HOST"],
|
if static_configured:
|
||||||
|
url_fmt = "{base}/icingaweb2/monitoring/list/services?service={service}&modifyFilter=1"
|
||||||
|
name = service_name
|
||||||
|
|
||||||
|
icinga_web_url = app.config.get("ICINGA_WEB_URL")
|
||||||
|
if not icinga_web_url:
|
||||||
|
icinga_web_url = "ICINGA_WEB_URL_NOT_SET:"
|
||||||
|
|
||||||
|
dummy_host=app.config.get("ASYNC_ICINGA_DUMMY_HOST")
|
||||||
|
if not dummy_host:
|
||||||
|
dummy_host = "ASYNC_ICINGA_DUMMY_HOST_NOT_SET:"
|
||||||
|
|
||||||
|
return url_fmt.format(base=icinga_web_url,
|
||||||
|
host=dummy_host,
|
||||||
service=name)
|
service=name)
|
||||||
|
|
||||||
|
|||||||
2
req.txt
2
req.txt
@@ -1,7 +1,7 @@
|
|||||||
pytimeparse
|
pytimeparse
|
||||||
flask
|
flask
|
||||||
flask-sqlalchemy
|
flask-sqlalchemy
|
||||||
flasl-wtf
|
flask-wtf
|
||||||
waitress
|
waitress
|
||||||
requests
|
requests
|
||||||
icinga2api
|
icinga2api
|
||||||
|
|||||||
196
server.py
196
server.py
@@ -12,7 +12,7 @@ import secrets
|
|||||||
|
|
||||||
import flask_wtf
|
import flask_wtf
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import StringField, SubmitField, BooleanField, DecimalField, HiddenField
|
from wtforms import StringField, SubmitField, BooleanField, DecimalField, HiddenField, SelectField
|
||||||
from wtforms.validators import DataRequired, Length
|
from wtforms.validators import DataRequired, Length
|
||||||
|
|
||||||
from sqlalchemy import Column, Integer, String, Boolean, or_, and_
|
from sqlalchemy import Column, Integer, String, Boolean, or_, and_
|
||||||
@@ -24,6 +24,7 @@ from flask_sqlalchemy import SQLAlchemy
|
|||||||
from sqlalchemy.sql.expression import func
|
from sqlalchemy.sql.expression import func
|
||||||
|
|
||||||
import icingatools
|
import icingatools
|
||||||
|
import smarttools
|
||||||
|
|
||||||
app = flask.Flask("Icinga Report In Gateway")
|
app = flask.Flask("Icinga Report In Gateway")
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ class Service(db.Model):
|
|||||||
token = Column(String)
|
token = Column(String)
|
||||||
timeout = Column(Integer)
|
timeout = Column(Integer)
|
||||||
owner = Column(String)
|
owner = Column(String)
|
||||||
|
special_type = Column(String)
|
||||||
|
|
||||||
staticly_configured = Column(Boolean)
|
staticly_configured = Column(Boolean)
|
||||||
|
|
||||||
@@ -57,6 +59,22 @@ class Status(db.Model):
|
|||||||
dt = datetime.datetime.fromtimestamp(self.timestamp)
|
dt = datetime.datetime.fromtimestamp(self.timestamp)
|
||||||
return dt.strftime("%d. %B %Y at %H:%M")
|
return dt.strftime("%d. %B %Y at %H:%M")
|
||||||
|
|
||||||
|
class SMARTStatus(db.Model):
|
||||||
|
|
||||||
|
__tablename__ = "smart"
|
||||||
|
|
||||||
|
service = Column(String, primary_key=True)
|
||||||
|
timestamp = Column(Integer, 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)
|
||||||
|
|
||||||
def buildReponseDict(status, service=None):
|
def buildReponseDict(status, service=None):
|
||||||
|
|
||||||
if not status:
|
if not status:
|
||||||
@@ -108,19 +126,34 @@ class EntryForm(FlaskForm):
|
|||||||
|
|
||||||
service = StringField("Service Name")
|
service = StringField("Service Name")
|
||||||
service_hidden = HiddenField("service_hidden")
|
service_hidden = HiddenField("service_hidden")
|
||||||
|
special_type = SelectField("Type", choices=["Default", "SMART"])
|
||||||
timeout = DecimalField("Timeout in days", default=30)
|
timeout = DecimalField("Timeout in days", default=30)
|
||||||
|
|
||||||
def create_entry(form, user):
|
def create_entry(form, user):
|
||||||
|
|
||||||
# TODO add entry to icinga
|
|
||||||
token = secrets.token_urlsafe(16)
|
token = secrets.token_urlsafe(16)
|
||||||
|
|
||||||
service_name = form.service.data or form.service_hidden.data
|
service_name = form.service.data or form.service_hidden.data
|
||||||
|
|
||||||
service = Service(service=service_name, timeout=int(form.timeout.data),
|
# keep token if modification #
|
||||||
owner=user, token=token)
|
s_tmp = db.session.query(Service).filter(Service.service == service_name).first()
|
||||||
|
if s_tmp:
|
||||||
|
token = s_tmp.token
|
||||||
|
if not token:
|
||||||
|
raise AssertionError("WTF Service without Token {}".format(service_name))
|
||||||
|
|
||||||
icingatools.create_service(user, service_name, app)
|
day_delta = datetime.timedelta(days=int(form.timeout.data))
|
||||||
|
|
||||||
|
special_type = form.special_type.data
|
||||||
|
if form.special_type == "Default":
|
||||||
|
special_type = None
|
||||||
|
|
||||||
|
service = Service(service=service_name, timeout=day_delta.total_seconds(),
|
||||||
|
owner=user, token=token, special_type=special_type)
|
||||||
|
|
||||||
|
# service.data set = create, service_hidden.data = modify #
|
||||||
|
if form.service.data:
|
||||||
|
icingatools.create_service(user, service_name, app)
|
||||||
|
|
||||||
db.session.merge(service)
|
db.session.merge(service)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -128,7 +161,7 @@ def create_entry(form, user):
|
|||||||
@app.route("/service-details")
|
@app.route("/service-details")
|
||||||
def service_details():
|
def service_details():
|
||||||
|
|
||||||
user = flask.request.headers.get("X-Forwarded-Preferred-Username")
|
user = str(flask.request.headers.get("X-Forwarded-Preferred-Username"))
|
||||||
service = flask.request.args.get("service")
|
service = flask.request.args.get("service")
|
||||||
|
|
||||||
# query service #
|
# query service #
|
||||||
@@ -137,15 +170,20 @@ def service_details():
|
|||||||
# validate #
|
# validate #
|
||||||
if not service:
|
if not service:
|
||||||
return ("{} not found".format("service"), 404)
|
return ("{} not found".format("service"), 404)
|
||||||
if service.owner and service.owner != user:
|
if service.owner and str(service.owner) != user:
|
||||||
return ("Services is not owned by {}".format(user))
|
return ("Services is not owned by {}".format(user))
|
||||||
|
|
||||||
status_list = db.session.query(Status).filter(Status.service==service.service).all()
|
status_list_query = db.session.query(Status).filter(Status.service==service.service)
|
||||||
|
status_list = status_list_query.order_by(sqlalchemy.desc(Status.timestamp)).limit(20).all()
|
||||||
|
|
||||||
icinga_link = icingatools.build_icinga_link_for_service(user, service.service, app)
|
icinga_link = icingatools.build_icinga_link_for_service(user, service.service,
|
||||||
|
service.staticly_configured, app)
|
||||||
|
|
||||||
|
smart_entry_list = db.session.query(SMARTStatus).filter(SMARTStatus.service==service.service)
|
||||||
|
smart_entry = smart_entry_list.order_by(SMARTStatus.timestamp.desc()).first()
|
||||||
|
|
||||||
return flask.render_template("service_info.html", service=service, flask=flask,
|
return flask.render_template("service_info.html", service=service, flask=flask,
|
||||||
user=user, status_list=status_list, icinga_link=icinga_link)
|
user=user, status_list=status_list, icinga_link=icinga_link, smart=smart_entry)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/entry-form", methods=["GET", "POST", "DELETE"])
|
@app.route("/entry-form", methods=["GET", "POST", "DELETE"])
|
||||||
@@ -154,7 +192,7 @@ def create_interface():
|
|||||||
user = str(flask.request.headers.get("X-Forwarded-Preferred-Username"))
|
user = str(flask.request.headers.get("X-Forwarded-Preferred-Username"))
|
||||||
|
|
||||||
# check if is delete #
|
# check if is delete #
|
||||||
operation = flask.request.args.get("operation")
|
operation = flask.request.args.get("operation")
|
||||||
if operation and operation == "delete" :
|
if operation and operation == "delete" :
|
||||||
|
|
||||||
service_delete_name = flask.request.args.get("service")
|
service_delete_name = flask.request.args.get("service")
|
||||||
@@ -171,19 +209,20 @@ def create_interface():
|
|||||||
return flask.redirect("/overview")
|
return flask.redirect("/overview")
|
||||||
|
|
||||||
form = EntryForm()
|
form = EntryForm()
|
||||||
|
|
||||||
# handle modification #
|
# handle modification #
|
||||||
modify_service_name = flask.request.args.get("service")
|
modify_service_name = flask.request.args.get("service")
|
||||||
if modify_service_name:
|
if modify_service_name:
|
||||||
service = db.session.query(Service).filter(Service.service == modify_service_name).first()
|
service = db.session.query(Service).filter(Service.service == modify_service_name).first()
|
||||||
if service and service.owner == user:
|
if service and service.owner == user:
|
||||||
form.service.default = service.service
|
form.service.default = service.service
|
||||||
form.timeout.default = service.timeout
|
form.special_type.default = service.special_type
|
||||||
|
form.timeout.default = datetime.timedelta(seconds=service.timeout).days
|
||||||
form.service_hidden.default = service.service
|
form.service_hidden.default = service.service
|
||||||
form.process()
|
form.process()
|
||||||
else:
|
else:
|
||||||
return ("Not a valid service to modify", 404)
|
return ("Not a valid service to modify", 404)
|
||||||
|
|
||||||
if flask.request.method == "POST":
|
if flask.request.method == "POST":
|
||||||
create_entry(form, user)
|
create_entry(form, user)
|
||||||
service_name = form.service.data or form.service_hidden.data
|
service_name = form.service.data or form.service_hidden.data
|
||||||
@@ -203,6 +242,7 @@ def reload():
|
|||||||
return ("", 204)
|
return ("", 204)
|
||||||
|
|
||||||
@app.route('/', methods=["GET", "POST"])
|
@app.route('/', methods=["GET", "POST"])
|
||||||
|
@app.route('/report', methods=["GET", "POST"])
|
||||||
def default():
|
def default():
|
||||||
if flask.request.method == "GET":
|
if flask.request.method == "GET":
|
||||||
|
|
||||||
@@ -247,7 +287,7 @@ def default():
|
|||||||
if not lastSuccess.timestamp == 0 and delta > timeout and latestInfoIsSuccess:
|
if not lastSuccess.timestamp == 0 and delta > timeout and latestInfoIsSuccess:
|
||||||
|
|
||||||
# lastes info is success but timed out #
|
# lastes info is success but timed out #
|
||||||
lastSuccess.info_text = "Service {} overdue since {}".format(service, str(delta))
|
lastSuccess.info_text = "Service {} overdue since {}".format(service, str(delta))
|
||||||
if timeout/delta > 0.9 or (delta - timeout) < datetime.timedelta(hours=12):
|
if timeout/delta > 0.9 or (delta - timeout) < datetime.timedelta(hours=12):
|
||||||
lastSuccess.status = "WARNING"
|
lastSuccess.status = "WARNING"
|
||||||
else:
|
else:
|
||||||
@@ -265,16 +305,27 @@ def default():
|
|||||||
elif flask.request.method == "POST":
|
elif flask.request.method == "POST":
|
||||||
|
|
||||||
# get variables #
|
# get variables #
|
||||||
service = flask.request.json["service"]
|
service = flask.request.json.get("service")
|
||||||
token = flask.request.json["token"]
|
token = flask.request.json.get("token")
|
||||||
status = flask.request.json["status"]
|
status = flask.request.json.get("status")
|
||||||
text = flask.request.json["info"]
|
text = flask.request.json.get("info") or "no_info"
|
||||||
timestamp = datetime.datetime.now().timestamp()
|
timestamp = datetime.datetime.now().timestamp()
|
||||||
|
|
||||||
|
smart = flask.request.json.get("smart")
|
||||||
|
|
||||||
|
# check smart json quoting problems #
|
||||||
|
if smart and type(smart) == str:
|
||||||
|
try:
|
||||||
|
smart = json.loads(smart)
|
||||||
|
except json.decoder.JSONDecodeError as e:
|
||||||
|
return ("Error in SMART-json {}".format(e), 415)
|
||||||
|
|
||||||
if not service:
|
if not service:
|
||||||
return ("'service' ist empty field in json", 400)
|
return ("'service' ist empty field in json", 400)
|
||||||
elif not token:
|
elif not token:
|
||||||
return ("'token' ist empty field in json", 400)
|
return ("'token' ist empty field in json", 400)
|
||||||
|
elif not status and not smart:
|
||||||
|
return ("'status' is empty field in json", 400)
|
||||||
|
|
||||||
# verify token & service in config #
|
# verify token & service in config #
|
||||||
verifiedServiceObj = db.session.query(Service).filter(
|
verifiedServiceObj = db.session.query(Service).filter(
|
||||||
@@ -283,33 +334,118 @@ def default():
|
|||||||
if not verifiedServiceObj:
|
if not verifiedServiceObj:
|
||||||
return ("Service ({}) with this token ({}) not found in DB".format(service, token), 401)
|
return ("Service ({}) with this token ({}) not found in DB".format(service, token), 401)
|
||||||
else:
|
else:
|
||||||
status = Status(service=service, timestamp=timestamp, status=status, info_text=text)
|
|
||||||
|
# handle a SMART-record submission (with errorhandling) #
|
||||||
|
if smart and not verifiedServiceObj.special_type == "SMART":
|
||||||
|
return ("SMART Field for non-SMART type service", 415)
|
||||||
|
elif smart:
|
||||||
|
text, status = record_and_check_smart(verifiedServiceObj,
|
||||||
|
timestamp, smart)
|
||||||
|
|
||||||
|
status = Status(service=service, timestamp=timestamp, status=status,
|
||||||
|
info_text=text)
|
||||||
db.session.merge(status)
|
db.session.merge(status)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return ("", 204)
|
return ("", 204)
|
||||||
else:
|
else:
|
||||||
return ("Method not implemented: {}".format(flask.request.method), 405)
|
return ("Method not implemented: {}".format(flask.request.method), 405)
|
||||||
|
|
||||||
|
def record_and_check_smart(service, timestamp, smart):
|
||||||
|
|
||||||
|
if "nvme_smart_health_information_log" in smart:
|
||||||
|
health_info = smart["nvme_smart_health_information_log"]
|
||||||
|
else:
|
||||||
|
health_info = smarttools.normalize(smart)
|
||||||
|
|
||||||
|
if not service.special_type == "SMART":
|
||||||
|
raise AssertionError("Trying to record SMART-record for non-SMART service")
|
||||||
|
|
||||||
|
# record the status #
|
||||||
|
smart_status = SMARTStatus(service=service.service, timestamp=timestamp,
|
||||||
|
temperature=health_info["temperature"],
|
||||||
|
critical_warning=health_info["critical_warning"],
|
||||||
|
unsafe_shutdowns=health_info["unsafe_shutdowns"],
|
||||||
|
power_cycles=health_info["power_cycles"],
|
||||||
|
power_on_hours=health_info["power_on_hours"],
|
||||||
|
available_spare=health_info.get("available_spare"),
|
||||||
|
model_number=smart.get("model_name"),
|
||||||
|
wearleveling_count=health_info.get("wearleveling_count"))
|
||||||
|
|
||||||
|
db.session.add(smart_status)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# check the status #
|
||||||
|
smart_last_query = db.session.query(SMARTStatus)
|
||||||
|
smart_last_query = smart_last_query.filter(SMARTStatus.service==service.service)
|
||||||
|
smart_last = smart_last_query.order_by(sqlalchemy.desc(SMARTStatus.timestamp)).first()
|
||||||
|
smart_second_last = smart_last_query.order_by(sqlalchemy.desc(
|
||||||
|
SMARTStatus.timestamp)).offset(1).first()
|
||||||
|
|
||||||
|
# last record (max 6 months ago) #
|
||||||
|
timestampt_minus_6m = datetime.datetime.now() - datetime.timedelta(days=180)
|
||||||
|
smart_old_query = smart_last_query.filter(
|
||||||
|
SMARTStatus.timestamp > timestampt_minus_6m.timestamp())
|
||||||
|
smart_old = smart_old_query.order_by(sqlalchemy.asc(SMARTStatus.timestamp)).first()
|
||||||
|
|
||||||
|
# critial != 0 #
|
||||||
|
if smart_last.critical_warning != 0:
|
||||||
|
return ("SMART reports disk critical => oO better do something about this", "CRITICAL")
|
||||||
|
|
||||||
|
# wearleveling < 20% (SAMSUNG only) #
|
||||||
|
if smart_last.wearleveling_count and smart_last.wearleveling_count <= 20:
|
||||||
|
return ("SMART report prefail disk (wear_level < 20%)", "CRITICAL")
|
||||||
|
|
||||||
|
# temp max > X #
|
||||||
|
if smart_last.temperature > 50:
|
||||||
|
return ("Disk Temperatur {}".format(smart_last.temperature), "CRITICAL")
|
||||||
|
|
||||||
|
# available_SSD spare #
|
||||||
|
spare_change = smart_old.available_spare - smart_last.available_spare
|
||||||
|
|
||||||
|
if smart_last.available_spare <= 25:
|
||||||
|
return ("SSD spare <25 ({}) YOUR DISK WILL DIE SOON".format(spare_change),
|
||||||
|
"CRITICAL")
|
||||||
|
elif smart_last.available_spare <= 50:
|
||||||
|
return ("SSD spare <50 ({})".format(spare_change), "WARNING")
|
||||||
|
elif spare_change >= 10:
|
||||||
|
return ("Strong degration in SSD spare space ({} in under 6 months)".format(
|
||||||
|
spare_change), "WARNING")
|
||||||
|
|
||||||
|
# unsafe_shutdowns +1 #
|
||||||
|
if smart_second_last.unsafe_shutdowns - smart_last.unsafe_shutdowns >= 1:
|
||||||
|
return ("Disk had {} unsafe shutdowns".format(smart_last.unsafe_shutdowns),
|
||||||
|
"WARNING")
|
||||||
|
|
||||||
|
return ("", "OK")
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
|
|
||||||
db.create_all()
|
db.create_all()
|
||||||
config = {}
|
config = {}
|
||||||
|
|
||||||
app.config["SECRET_KEY"] = secrets.token_urlsafe(64)
|
app.config["SECRET_KEY"] = secrets.token_urlsafe(64)
|
||||||
|
|
||||||
|
# load app config #
|
||||||
|
config_dir = app.config["JSON_CONFIG_DIR"]
|
||||||
|
main_config_file = "./{}/config.json".format(config_dir)
|
||||||
|
print(main_config_file)
|
||||||
|
if os.path.isfile(main_config_file):
|
||||||
|
with open(main_config_file) as config_file:
|
||||||
|
config_data = json.load(config_file)
|
||||||
|
app.config |= config_data
|
||||||
|
print(app.config)
|
||||||
|
|
||||||
if os.path.isfile(app.config["JSON_CONFIG_FILE"]):
|
if os.path.isfile(app.config["JSON_CONFIG_FILE"]):
|
||||||
with open(app.config["JSON_CONFIG_FILE"]) as f:
|
with open(app.config["JSON_CONFIG_FILE"]) as f:
|
||||||
config |= json.load(f)
|
config |= json.load(f)
|
||||||
elif os.path.isdir(app.config["JSON_CONFIG_DIR"]):
|
elif os.path.isdir(app.config["JSON_CONFIG_DIR"]):
|
||||||
for fname in os.listdir(app.config["JSON_CONFIG_DIR"]):
|
for fname in os.listdir(app.config["JSON_CONFIG_DIR"]):
|
||||||
fullpath = os.path.join(app.config["JSON_CONFIG_DIR"], fname)
|
fullpath = os.path.join(app.config["JSON_CONFIG_DIR"], fname)
|
||||||
if fname.endswith(".json"):
|
if fname.endswith(".json") and not fname == "config.json":
|
||||||
with open(fullpath) as f:
|
with open(fullpath) as f:
|
||||||
config |= json.load(f)
|
config |= json.load(f)
|
||||||
|
|
||||||
# create dummy host #
|
|
||||||
icingatools.create_master_host(app)
|
|
||||||
|
|
||||||
if not config:
|
if not config:
|
||||||
print("No valid configuration found - need at least one service")
|
print("No valid configuration found - need at least one service")
|
||||||
return
|
return
|
||||||
@@ -317,11 +453,17 @@ def create_app():
|
|||||||
for key in config:
|
for key in config:
|
||||||
timeout = timeparse.timeparse(config[key]["timeout"])
|
timeout = timeparse.timeparse(config[key]["timeout"])
|
||||||
staticly_configured = True
|
staticly_configured = True
|
||||||
db.session.merge(Service(service=key, token=config[key]["token"],
|
db.session.merge(Service(service=key, token=config[key]["token"],
|
||||||
staticly_configured=staticly_configured, timeout=timeout,
|
staticly_configured=staticly_configured, timeout=timeout,
|
||||||
owner=config[key]["owner"]))
|
owner=config[key]["owner"]))
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
||||||
|
|||||||
49
smarttools.py
Normal file
49
smarttools.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
def normalize(smart):
|
||||||
|
'''Load different types of SMART outputs'''
|
||||||
|
|
||||||
|
ret = dict()
|
||||||
|
ret.update({ "temperature" : 0 })
|
||||||
|
ret.update({ "critical_warning" : 0 })
|
||||||
|
ret.update({ "unsafe_shutdowns" : 0 })
|
||||||
|
ret.update({ "power_cycles" : 0 })
|
||||||
|
ret.update({ "power_on_hours" : 0 })
|
||||||
|
ret.update({ "available_spare" : 100 })
|
||||||
|
ret.update({ "wearleveling_count" : 100 })
|
||||||
|
|
||||||
|
if "ata_smart_attributes" in smart:
|
||||||
|
|
||||||
|
# get main table #
|
||||||
|
table = smart["ata_smart_attributes"]["table"]
|
||||||
|
|
||||||
|
# temperatur #
|
||||||
|
ret["temperature"] = smart["temperature"]["current"]
|
||||||
|
|
||||||
|
for el in table:
|
||||||
|
|
||||||
|
# look for relevant metrics #
|
||||||
|
name = el["name"].lower()
|
||||||
|
target_name = el["name"].lower() # name in return map
|
||||||
|
|
||||||
|
# handle value mapping #
|
||||||
|
use_raw = False
|
||||||
|
if name == "used_rsvd_blk_cnt_tot":
|
||||||
|
target_name = "available_spare"
|
||||||
|
elif name == "power_cylce_count":
|
||||||
|
target_name = "power_cycles"
|
||||||
|
use_raw = True
|
||||||
|
elif name == "power_on_hours":
|
||||||
|
target_name = "power_on_hours"
|
||||||
|
use_raw = True
|
||||||
|
|
||||||
|
# check if metric should be recorded #
|
||||||
|
if target_name in ret:
|
||||||
|
|
||||||
|
# set return dict #
|
||||||
|
if use_raw:
|
||||||
|
value = el["raw"]["value"]
|
||||||
|
else:
|
||||||
|
value = el["value"]
|
||||||
|
|
||||||
|
ret[target_name] = value
|
||||||
|
|
||||||
|
return ret
|
||||||
@@ -81,6 +81,7 @@ body{
|
|||||||
}
|
}
|
||||||
|
|
||||||
.last-status{
|
.last-status{
|
||||||
|
display: inline-block;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
box-shadow: 0px 0px 4px 3px rgba(0,0,0,0.5);
|
box-shadow: 0px 0px 4px 3px rgba(0,0,0,0.5);
|
||||||
background: linear-gradient(to top, #cfc6b054 0%, #cfcfcfc4 100%);
|
background: linear-gradient(to top, #cfc6b054 0%, #cfcfcfc4 100%);
|
||||||
@@ -154,6 +155,22 @@ body{
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.smart-info{
|
||||||
|
font-family: monospace;
|
||||||
|
|
||||||
|
padding-top: 2px;
|
||||||
|
padding-top: 2px;
|
||||||
|
padding-left: 5px;
|
||||||
|
|
||||||
|
padding-left: 5px;
|
||||||
|
|
||||||
|
color: black;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.box{
|
.box{
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
@@ -176,3 +193,11 @@ body{
|
|||||||
.clear {
|
.clear {
|
||||||
clear: both;
|
clear: both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hover:hover{
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
td{
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,9 @@
|
|||||||
</br>
|
</br>
|
||||||
{{ form.timeout.label }} {{ form.timeout() }} </br>
|
{{ form.timeout.label }} {{ form.timeout() }} </br>
|
||||||
|
|
||||||
|
</br>
|
||||||
|
{{ form.special_type.label }} {{ form.special_type() }} </br>
|
||||||
|
|
||||||
{% if is_modification %}
|
{% if is_modification %}
|
||||||
<input class="form-button mt-4" type="submit" value="Send Modification">
|
<input class="form-button mt-4" type="submit" value="Send Modification">
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -3,7 +3,33 @@
|
|||||||
<body>
|
<body>
|
||||||
{% include "navbar.html" %}
|
{% include "navbar.html" %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a href="{{ icinga_link }}">Icinga Link</a>
|
|
||||||
|
<script>
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
function submit_manual(){
|
||||||
|
fetch("/report", {
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
"service" : "{{ service.service }}",
|
||||||
|
"token" : "{{ service.token }}",
|
||||||
|
"status" : "OK",
|
||||||
|
"info" : "Submitted from Web-Interface" })
|
||||||
|
}).then( () => {
|
||||||
|
sleep(1000).then( () => {
|
||||||
|
window.location.reload()
|
||||||
|
})
|
||||||
|
}).catch( () => {
|
||||||
|
alert("Submission failed, see console for details.")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<h2 class="service-name">Service: {{ service.service }}</h2>
|
<h2 class="service-name">Service: {{ service.service }}</h2>
|
||||||
{% if service.staticly_configured %}
|
{% if service.staticly_configured %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -20,15 +46,23 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="last-status">
|
<a href="{{ icinga_link }}" style="clear: both;" class="last-status hover mr-3">
|
||||||
{% if status_list | length > 0 %}
|
<p style="color: darkred;">Show in Icinga</p>
|
||||||
<p class="{{ status_list[0].status }}">
|
</a>
|
||||||
{{ status_list[0].status }} submitted on {{ status_list[0].human_date() }}
|
|
||||||
</p>
|
<button style="clear: both;" class="last-status hover mr-3" onclick="submit_manual()">
|
||||||
{% else %}
|
<p style="color: darkred;">Submit manual OK</p>
|
||||||
<p style="color: darkred;">No status for this service submitted</p>
|
</button>
|
||||||
{% endif %}
|
|
||||||
</div>
|
<div class="last-status">
|
||||||
|
{% if status_list | length > 0 %}
|
||||||
|
<p class="{{ status_list[0].status }}">
|
||||||
|
{{ status_list[0].status }} submitted on {{ status_list[0].human_date() }}
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p style="color: darkred;">No status for this service submitted</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="clear p-4 box mt-4 mb-3" style="display: none;">
|
<div class="clear p-4 box mt-4 mb-3" style="display: none;">
|
||||||
<div class="service-timeout">Timeout: {{ service.timeout }} days</div>
|
<div class="service-timeout">Timeout: {{ service.timeout }} days</div>
|
||||||
@@ -37,24 +71,71 @@
|
|||||||
class="service-token">Secret Token: {{ service.token }}</div>
|
class="service-token">Secret Token: {{ service.token }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if service.special_type == "SMART" %}
|
||||||
|
<div class="clear smart-info mt-3" style="background-color: orange;">
|
||||||
|
Smart Monitor {% if smart %} for: {{ smart.model_number }} {% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="clear smart-info mt-3" style="background-color: orange;">
|
||||||
|
Example below requires smartmontools ("smartctl") in PATH.
|
||||||
|
On Linux this is usually available via the package manager,
|
||||||
|
on Windows install it from the
|
||||||
|
<a style="text-decoration: underline; color: #5000e1; font-weight: bold;" href="https://www.smartmontools.org/wiki/Download#InstalltheWindowspackage">offical page</a>.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if smart %}
|
||||||
|
<h5 class="clear my-4">Linux</h5>
|
||||||
|
{% else %}
|
||||||
<h5 class="clear my-4">Curl</h5>
|
<h5 class="clear my-4">Curl</h5>
|
||||||
|
{% endif %}
|
||||||
<div class="ml-3 example">
|
<div class="ml-3 example">
|
||||||
|
{% if smart %}
|
||||||
|
SMART='{ <br>
|
||||||
|
<div class="example-indent">
|
||||||
|
"service" : "{{ service.service }}", <br>
|
||||||
|
"token" : "{{ service.token }}", <br>
|
||||||
|
"status" : "N/A", <br>
|
||||||
|
"smart" : '$(/sbin/smartctl -a /dev/nvme0n1 --json)' <br>
|
||||||
|
</div>
|
||||||
|
}' <br><br>
|
||||||
|
curl -X POST -H "Content-Type: application/json" \ <br>
|
||||||
|
<div class="example-indent">
|
||||||
|
--data "${SMART}" \ <br>
|
||||||
|
{{ flask.request.url_root.replace("http://", "https://" )}}report
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
curl -X POST \ <br>
|
curl -X POST \ <br>
|
||||||
<div class="example-indent">
|
<div class="example-indent">
|
||||||
-H "Content-Type: application/json" \ <br>
|
-H "Content-Type: application/json" \ <br>
|
||||||
-d '{ "service" : "{{ service.service }}",
|
-d '{ "service" : "{{ service.service }}",
|
||||||
"token" : "{{ service.token }}", <br>
|
"token" : "{{ service.token }}", <br>
|
||||||
"status" : "OK", "info" : "Free Text Information here" }' \<br>
|
"status" : "OK", "info" : "Free Text Information here" }' \<br>
|
||||||
{{ flask.request.url_root }}
|
{{ flask.request.url_root.replace("http://", "https://" )}}report
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if smart %}
|
||||||
|
<h5 class="my-4">Windows</h5>
|
||||||
|
<div class="ml-3 example">
|
||||||
|
$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
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
<h5 class="my-4">Python</h5>
|
<h5 class="my-4">Python</h5>
|
||||||
<div class="ml-3 example">
|
<div class="ml-3 example">
|
||||||
import requests
|
import requests<br>
|
||||||
requests.post("{{ flask.request.url_root }}",<br>
|
requests.post("{{ flask.request.url_root.replace("http://", "https://")}}report",
|
||||||
|
<br>
|
||||||
<div class="example-indent-double">
|
<div class="example-indent-double">
|
||||||
json= { "service_name" : "{{ service.service }}", <br>
|
json= { "service" : "{{ service.service }}", <br>
|
||||||
<div class="example-indent-double">
|
<div class="example-indent-double">
|
||||||
"token" : "{{ service.token }}", <br>
|
"token" : "{{ service.token }}", <br>
|
||||||
"status" : "OK", </br>
|
"status" : "OK", </br>
|
||||||
@@ -62,24 +143,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<table class="mb-4 mt-5 status-table">
|
<table class="mb-4 mt-5 status-table">
|
||||||
<thead>
|
<thead>
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Info</th>
|
<th>Info</th>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody class="mt-2">
|
<tbody class="mt-2">
|
||||||
{% for status in status_list %}
|
{% for status in status_list %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ status.human_date() }}</td>
|
<td>{{ status.human_date() }}</td>
|
||||||
<td class="{{ status.status }}">{{ status.status }}</td>
|
<td class="{{ status.status }}">{{ status.status }}</td>
|
||||||
<td>{{ status.info_text }}</td>
|
<td>{{ status.info_text }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user