diff --git a/app/portal/__init__.py b/app/portal/__init__.py index aff3ff1..544dc07 100644 --- a/app/portal/__init__.py +++ b/app/portal/__init__.py @@ -1,9 +1,11 @@ +import json from datetime import datetime, timedelta, timezone from typing import Optional from flask import Blueprint, render_template, request, url_for, redirect from flask.typing import ResponseReturnValue from jinja2.utils import markupsafe +from markupsafe import Markup from sqlalchemy import desc, or_, func from app.alarms import alarms_for @@ -24,6 +26,7 @@ from app.portal.onion import bp as onion from app.portal.pool import bp as pool from app.portal.proxy import bp as proxy from app.portal.smart_proxy import bp as smart_proxy +from app.portal.storage import bp as storage from app.portal.webhook import bp as webhook portal = Blueprint("portal", __name__, template_folder="templates", static_folder="static") @@ -38,6 +41,7 @@ portal.register_blueprint(onion, url_prefix="/onion") portal.register_blueprint(pool, url_prefix="/pool") portal.register_blueprint(proxy, url_prefix="/proxy") portal.register_blueprint(smart_proxy, url_prefix="/smart") +portal.register_blueprint(storage, url_prefix="/state") portal.register_blueprint(webhook, url_prefix="/webhook") @@ -91,6 +95,13 @@ def describe_brn(s: str) -> ResponseReturnValue: return s +@portal.app_template_filter("pretty_json") +def pretty_json(input: Optional[str]): + if not input: + return "None" + return json.dumps(json.loads(input), indent=2) + + def total_origins_blocked() -> int: count = 0 for o in Origin.query.filter(Origin.destroyed.is_(None)).all(): diff --git a/app/portal/automation.py b/app/portal/automation.py index a33b911..01180fe 100644 --- a/app/portal/automation.py +++ b/app/portal/automation.py @@ -4,11 +4,12 @@ from typing import Optional from flask import render_template, flash, Response, Blueprint, current_app from flask.typing import ResponseReturnValue from flask_wtf import FlaskForm -from sqlalchemy import exc +from sqlalchemy import exc, desc from wtforms import SubmitField, BooleanField from app.extensions import db from app.models.automation import Automation, AutomationLogs +from app.models.tfstate import TerraformState from app.portal.util import view_lifecycle, response_404 bp = Blueprint("automation", __name__) @@ -27,16 +28,17 @@ class EditAutomationForm(FlaskForm): # type: ignore @bp.route("/list") def automation_list() -> ResponseReturnValue: - automations = Automation.query.filter( - Automation.destroyed.is_(None)).order_by(Automation.description).all() automations = list(filter( lambda a: a.short_name not in current_app.config.get('HIDDEN_AUTOMATIONS', []), - automations + Automation.query.filter( + Automation.destroyed.is_(None)).order_by(Automation.description).all() )) + states = {tfs.key: tfs for tfs in TerraformState.query.all()} return render_template("list.html.j2", title="Automation Jobs", item="automation", items=automations, + states=states, **_SECTION_TEMPLATE_VARS) @@ -58,7 +60,8 @@ def automation_edit(automation_id: int) -> ResponseReturnValue: flash("Saved changes to bridge configuration.", "success") except exc.SQLAlchemyError: flash("An error occurred saving the changes to the bridge configuration.", "danger") - logs = AutomationLogs.query.filter(AutomationLogs.automation_id == automation.id).order_by(AutomationLogs.added).all() + logs = AutomationLogs.query.filter(AutomationLogs.automation_id == automation.id).order_by( + desc(AutomationLogs.added)).limit(5).all() return render_template("automation.html.j2", automation=automation, logs=logs, diff --git a/app/portal/storage.py b/app/portal/storage.py new file mode 100644 index 0000000..b0e4379 --- /dev/null +++ b/app/portal/storage.py @@ -0,0 +1,69 @@ +from datetime import datetime +from typing import Optional + +from flask import render_template, flash, Response, Blueprint, current_app +from flask.typing import ResponseReturnValue +from flask_wtf import FlaskForm +from sqlalchemy import exc +from wtforms import SubmitField, BooleanField + +from app.extensions import db +from app.models.automation import Automation, AutomationLogs +from app.models.tfstate import TerraformState +from app.portal.util import view_lifecycle, response_404 + +bp = Blueprint("storage", __name__) + + +_SECTION_TEMPLATE_VARS = { + "section": "automation", + "help_url": "https://bypass.censorship.guide/user/automation.html" +} + + +class EditStorageForm(FlaskForm): # type: ignore + force_unlock = BooleanField('Force Unlock') + submit = SubmitField('Save Changes') + + +@bp.route('/edit/', methods=['GET', 'POST']) +def storage_edit(storage_key: str) -> ResponseReturnValue: + storage: Optional[TerraformState] = TerraformState.query.filter(TerraformState.key == storage_key).first() + if storage is None: + return Response(render_template("error.html.j2", + header="404 Storage Key Not Found", + message="The requested storage could not be found.", + **_SECTION_TEMPLATE_VARS), + status=404) + form = EditStorageForm() + if form.validate_on_submit(): + if form.force_unlock.data: + storage.lock = None + storage.updated = datetime.utcnow() + try: + db.session.commit() + flash("Storage has been force unlocked.", "success") + except exc.SQLAlchemyError: + flash("An error occurred unlocking the storage.", "danger") + return render_template("storage.html.j2", + storage=storage, + form=form, + **_SECTION_TEMPLATE_VARS) + + +@bp.route("/kick/", methods=['GET', 'POST']) +def automation_kick(automation_id: int) -> ResponseReturnValue: + automation = Automation.query.filter( + Automation.id == automation_id, + Automation.destroyed.is_(None)).first() + if automation is None: + return response_404("The requested bridge configuration could not be found.") + return view_lifecycle( + header="Kick automation timer?", + message=automation.description, + section="automation", + success_view="portal.automation.automation_list", + success_message="This automation job will next run within 1 minute.", + resource=automation, + action="kick" + ) diff --git a/app/portal/templates/list.html.j2 b/app/portal/templates/list.html.j2 index 961d493..8e73de8 100644 --- a/app/portal/templates/list.html.j2 +++ b/app/portal/templates/list.html.j2 @@ -14,7 +14,7 @@ {% if section == "alarm" %} {{ alarms_table(items) }} {% elif item == "automation" %} - {{ automations_table(items) }} + {{ automations_table(items, states) }} {% elif item == "bridge configuration" %} {{ bridgeconfs_table(items) }} {% elif item == "bridge" %} diff --git a/app/portal/templates/storage.html.j2 b/app/portal/templates/storage.html.j2 new file mode 100644 index 0000000..9d9a124 --- /dev/null +++ b/app/portal/templates/storage.html.j2 @@ -0,0 +1,21 @@ +{% extends "base.html.j2" %} +{% from 'bootstrap5/form.html' import render_form %} +{% from "tables.html.j2" import automation_logs_table %} + +{% block content %} +

State Storage

+

{{ storage.key }}

+ +
+ {{ render_form(form) }} +
+ +

Storage

+ +

Current Lock

+
{{ storage.lock | pretty_json }}
+ +

State Dump

+
{{ storage.state | pretty_json }}
+ +{% endblock %} diff --git a/app/portal/templates/tables.html.j2 b/app/portal/templates/tables.html.j2 index 8ba5d50..23b3e3f 100644 --- a/app/portal/templates/tables.html.j2 +++ b/app/portal/templates/tables.html.j2 @@ -114,7 +114,7 @@ {{ instances_table("eotk", instances) }} {% endmacro %} -{% macro automations_table(automations) %} +{% macro automations_table(automations, states) %}
@@ -122,6 +122,7 @@ + @@ -141,6 +142,15 @@ {% endif %} +
Description Status EnabledStorage Last Run Next Run Actions {% if automation.enabled %}✅{% else %}❌{% endif %} + {% if automation.short_name in states %} + + {% if states[automation.short_name].lock %}🔒{% else %}🔓{% endif %} + + {% else %} + + {% endif %} + {{ automation.last_run | format_datetime }} {{ automation.next_run | format_datetime }}