~martijnbraam/pts-centralcontroller

885041a3835284ac5aeaea45c368abc234e9b1ea — Martijn Braam a month ago 72807b5
Integrate with MQTT for job logging
M pts_central/__init__.py => pts_central/__init__.py +17 -0
@@ 5,6 5,7 @@ import click
from flask import Flask
from flask.cli import with_appcontext
from flask_login import LoginManager
from flask_multimqtt import MultiMQTT

from .config import Config
from flask_sqlalchemy import SQLAlchemy


@@ 20,17 21,23 @@ db = SQLAlchemy(app)
login_manager = LoginManager(app)
login_manager.login_view = 'authentication.login'
migrate = Migrate(app, db, render_as_batch=True)
mqtt = MultiMQTT(app)
mqtt.connect()

from . import models
from .frontend.dashboard import blueprint_dashboard
from .frontend.authentication import blueprint_auth
from .frontend.admin import blueprint_admin
from .frontend.job import blueprint_job
from .frontend.api import blueprint_api
from .frontend.artifact import blueprint_artifact

app.register_blueprint(blueprint_dashboard)
app.register_blueprint(blueprint_auth)
app.register_blueprint(blueprint_admin)
app.register_blueprint(blueprint_job)
app.register_blueprint(blueprint_api)
app.register_blueprint(blueprint_artifact)


@login_manager.user_loader


@@ 38,6 45,16 @@ def load_user(user_id):
    return models.User.get(user_id)


@login_manager.request_loader
def request_loader(request):
    api_key = request.headers.get('Authorization')
    if api_key:
        controller = models.Controller.query.filter_by(secret=api_key).first()
        if controller:
            return controller
    return None


@app.cli.command('create-user')
@click.argument("username")
@click.option("--admin/--no-admin", default=False)

M pts_central/config.py => pts_central/config.py +3 -0
@@ 7,6 7,9 @@ class Config:
    WTF_CSRF_SECRET_KEY = os.environ.get("SECRET")
    SECRET_KEY = os.environ.get("SECRET")

    MQTT_URI = os.environ.get("MQTT_URI")
    DATA_DIR = os.environ.get("DATA_DIR")

    def __init__(self):
        if not os.environ.get("DATABASE"):
            print("DATABASE is not defined")

A pts_central/frontend/api.py => pts_central/frontend/api.py +142 -0
@@ 0,0 1,142 @@
import datetime
import json
import os.path

from flask import Blueprint, request, render_template, jsonify, abort, url_for
from flask_login import login_required, current_user

from pts_central import app, db, mqtt
from pts_central.models import Job, TaskStatus, Device, DeviceType, Controller, Task, DeviceStatus

blueprint_api = Blueprint('api1', __name__, url_prefix='/api/v1')


@blueprint_api.route('/controller/handshake')
@login_required
def controller_handshake():
    # Get requested device id
    device_id = int(request.json['device'])
    device = Device.get_or_404(device_id)
    if device.controller_id != current_user.id:
        return abort(403)

    # current_user is the Controller row due to api login
    username = f'controller-{current_user.id}'
    password = current_user.secret
    uri = f'mqtt://{username}:{password}@127.0.0.1/pts'

    topic = mqtt.register_topic('device/<int:device_id>/tasks')

    mqtt.dynsec_add_client(username, password, roles=['pts'])
    topics = {
        'tasks_topic': topic.topic_for(absolute=True, device_id=device_id),
        'status_topic': mqtt.topic_for('mqtt_device_status', absolute=True, device_id=device_id),
    }
    return jsonify(mqtt_uri=uri, id=current_user.id, **topics)


@blueprint_api.route('/device/<int:device_id>/get-task')
@login_required
def device_get_task(device_id):
    device = Device.get_or_404(device_id)

    if device.controller_id != current_user.id:
        return abort(403)

    task = Task.query.filter_by(device_id=device.id, status=TaskStatus.pending).order_by(Task.id).first()
    if task is None:
        # If there is no queued task for this specific device, check if there's a task for the devicetype that is not
        # allocated yet
        task = Task.query.filter_by(devicetype_id=device.devicetype_id, device_id=None).order_by(Task.id).first()

        if task is None:
            # Nothing to queue or run
            return jsonify({})

        # Allocate this devicetype task to this specific device
        task.device_id = device.id
        db.session.add(task)
        db.session.commit()
    job = Job.get(task.id)

    # Get a list of artifact URLs for the controller to download
    artifacts = {}
    for a in job.artifacts:
        artifacts[a.name] = url_for('artifact.get', artifact_id=a.id, _external=True)

    return jsonify({
        'task': task.id,
        'job': job.id,
        'manifest': job.manifest,
        'artifacts': artifacts
    })


@blueprint_api.route('/device/<int:device_id>/start-task/<int:task_id>', methods=['POST'])
@login_required
def device_start_task(device_id, task_id):
    device = Device.get_or_404(device_id)
    task = Task.get_or_404(task_id)
    if task.device_id != device.id:
        return abort(400)

    # Update device and task state
    task.status = TaskStatus.running
    device.status = DeviceStatus.running
    db.session.add(device)
    db.session.add(task)
    db.session.commit()

    # Send the controller the MQTT topic to stream the log to
    return jsonify(
        topic=mqtt.topic_for('mqtt_task_log', absolute=True, task_id=task.id),
    )


@mqtt.topic('controller/<int:controller_id>/status')
def mqtt_controller_status(client, message, controller_id):
    controller = Controller.get(controller_id)
    controller.last_contact = datetime.datetime.now()
    db.session.add(controller)
    db.session.commit()


@mqtt.topic('device/<int:device_id>/status')
def mqtt_device_status(client, message, device_id):
    device = Device.get(device_id)
    if message.payload == b'idle':
        device.status = DeviceStatus.idle
    elif message.payload == b'running':
        device.status = DeviceStatus.running
    elif message.payload == b'maintenance':
        device.status = DeviceStatus.maintenance
    elif message.payload == b'offline':
        device.status = DeviceStatus.offline
    elif message.payload == b'error':
        device.status = DeviceStatus.error
    else:
        return

    db.session.add(device)
    db.session.commit()


@mqtt.topic('task/<int:task_id>/log')
def mqtt_task_log(client, message, task_id):
    task = Task.get(task_id)
    jobdir = os.path.join(app.config['DATA_DIR'], 'logs', str(task.job_id))
    os.makedirs(jobdir, exist_ok=True)
    with open(os.path.join(jobdir, str(task.id)), 'a') as handle:
        handle.write(message.payload.decode() + "\n")

    data = json.loads(message.payload.decode())
    if 'r' in data:
        if data['r']:
            task.status = TaskStatus.success
        else:
            task.status = TaskStatus.failed
            job = Job.get(task.job_id)
            job.status = TaskStatus.failed
            db.session.add(job)
        db.session.add(task)
        db.session.commit()

M pts_central/frontend/job.py => pts_central/frontend/job.py +34 -1
@@ 1,11 1,13 @@
import datetime
import json
import os

import flask_login
from flask import Blueprint, request, render_template, redirect, url_for, make_response
from flask_login import login_required

from pts_central import app, db
from pts_central.models import Job, TaskStatus, Device, DeviceType
from pts_central.models import Job, TaskStatus, Device, DeviceType, Task

blueprint_job = Blueprint('job', __name__)



@@ 48,3 50,34 @@ def manifest(jid):
    response = make_response(job.manifest, 200)
    response.mimetype = 'text/plain'
    return response


@blueprint_job.route('/job/<int:jid>/task/<int:tid>')
def view_task(jid, tid):
    job = Job.get_or_404(jid)
    task = Task.get_or_404(tid)

    log = []
    logfile = os.path.join(app.config['DATA_DIR'], 'logs', str(job.id), str(task.id))
    lastsection = None
    lasttype = None
    if os.path.isfile(logfile):
        with open(logfile, 'r') as handle:
            for line in handle.readlines():
                line = json.loads(line)
                if line['s'] != lastsection:
                    lastsection = line['s']
                    lasttype = None
                    log.append({'section': lastsection, 'blocks': []})
                if 'l' in line:
                    ltype = 'log'
                elif 'ui' in line or 'uo' in line:
                    ltype = 'usb'
                elif 'r' in line:
                    ltype = 'result'
                if ltype != lasttype:
                    lasttype = ltype
                    log[-1]['blocks'].append({'block': ltype, 'events': []})
                print(line)
                log[-1]['blocks'][-1]['events'].append(line)
    return render_template('task.html', job=job, task=task, log=log)

M pts_central/models.py => pts_central/models.py +25 -5
@@ 29,6 29,14 @@ class BaseRow(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    created = db.Column(db.DateTime)

    @classmethod
    def get(cls, id):
        return cls.query.get(int(id))

    @classmethod
    def get_or_404(cls, id):
        return cls.query.get_or_404(int(id))


class User(BaseRow, UserMixin):
    username = db.Column(db.String, unique=True)


@@ 42,10 50,6 @@ class User(BaseRow, UserMixin):
    def check_password(self, password):
        return check_password_hash(self.password, password)

    @classmethod
    def get(cls, id):
        return cls.query.get(int(id))


class Architecture(BaseRow):
    name = db.Column(db.String)


@@ 59,6 63,7 @@ class DeviceType(BaseRow):
    name = db.Column(db.String)
    architecture_id = db.Column(db.Integer, db.ForeignKey('architecture.id', name="fk_architecture_id"))
    chipset_id = db.Column(db.Integer, db.ForeignKey('chipset.id', name="fk_chipset_id"))
    tasks = db.relationship('Task', backref='devicetype')


class Device(BaseRow):


@@ 74,13 79,15 @@ class Device(BaseRow):
    variables = db.relationship('Variable', cascade='all, delete-orphan')
    secret = db.Column(db.String)
    status = db.Column(sau.ChoiceType(DeviceStatus, impl=db.String()), nullable=False, default=DeviceStatus.offline)
    tasks = db.relationship('Task', backref='device')


class Controller(BaseRow):
class Controller(BaseRow, UserMixin):
    enabled = db.Column(db.Boolean)
    name = db.Column(db.String)
    secret = db.Column(db.String)
    maintainer_id = db.Column(db.Integer, db.ForeignKey('user.id', name="fk_maintainer_id"))
    last_contact = db.Column(db.DateTime)


class Job(BaseRow):


@@ 89,12 96,25 @@ class Job(BaseRow):
    description = db.Column(db.Unicode(4096))
    manifest = db.Column(db.Unicode(4096))
    status = db.Column(sau.ChoiceType(TaskStatus, impl=db.String()), nullable=False, default=TaskStatus.pending)
    tasks = db.relationship('Task', backref='job')
    artifacts = db.relationship('Artifact', backref='job')


class Artifact(BaseRow):
    # Artifacts exist on the job and task level. Jobs can have artifacts submitted together with the manifest and
    # The results of tasks get uploaded as artifacts related to the task
    job_id = db.Column(db.Integer, db.ForeignKey('job.id', name="fk_job_id"))
    task_id = db.Column(db.Integer, db.ForeignKey('task.id', name="fk_task_id"))
    name = db.Column(db.String)
    size = db.Column(db.Integer)


class Task(BaseRow):
    job_id = db.Column(db.Integer, db.ForeignKey('job.id', name="fk_job_id"))
    device_id = db.Column(db.Integer, db.ForeignKey('device.id', name="fk_device_id"))
    devicetype_id = db.Column(db.Integer, db.ForeignKey('device_type.id', name="fk_devicetype_id"))
    status = db.Column(sau.ChoiceType(TaskStatus, impl=db.String()), nullable=False, default=TaskStatus.pending)
    artifacts = db.relationship('Artifact', backref='task')


class Variable(BaseRow):

M pts_central/static/css/style.css => pts_central/static/css/style.css +67 -2
@@ 238,10 238,75 @@ dl dd {
  width: 0.8em;
  vertical-align: -0.25em;
}
.text-danger {
.text-danger,
.result.failed {
  color: #dc3545 !important;
}
.text-success {
.text-success,
.result.success {
  color: #28a745 !important;
}
.result.running {
  color: #17a2b8 !important;
}
.result.queued {
  color: #757575 !important;
}
/****************************/
/**  Log                   **/
/****************************/
div.log {
  font-size: 0.9em;
}
div.log .block.log {
  background: #0a0c0d;
  color: #f8f9fa;
}
div.log .block.log table.table-code {
  padding: 0;
  margin: 0;
  width: 100%;
  background: transparent;
  border-collapse: collapse;
}
div.log .block.log table.table-code td {
  padding: 0 8px;
}
div.log .block.log table.table-code tr td:first-child {
  text-align: right;
  width: 10%;
  border-right: 1px solid #545454;
  vertical-align: top;
}
div.log .block.log table.table-code tr td.controller pre {
  color: #939393;
}
div.log .block.log table.table-code tr td.flasher pre {
  color: lightseagreen;
}
div.log .block.log pre {
  background: transparent;
  color: #f8f9fa;
  padding: 0;
  margin: 0;
  white-space: pre-wrap;
}
div.log h4 {
  padding: 0;
  margin: 0;
  margin-bottom: 10px;
  font-size: 1em;
  text-align: center;
}
div.log .block.usb {
  background: #f8f9fa;
  padding: 15px;
  border-radius: 4px;
  max-width: 80%;
  margin: 0 auto;
}
div.log .block.usb pre {
  padding: 0;
  margin: 0;
}
/*# sourceMappingURL=style.css.map */
\ No newline at end of file

M pts_central/static/css/style.css.map => pts_central/static/css/style.css.map +1 -1
@@ 1,1 1,1 @@
{"version":3,"sources":["style.less"],"names":[],"mappings":";;;;;;AAWA;EACE,sBAAA;;AAGF,CAAC;EACC,cAAA;;AAEA,CAHD,MAGE;EACC,cAAA;;AAIJ;AAAM;EACJ,gBAAA;EACA,eAAA;EACA,gBAAA;EACA,uBAAA;EACA,UAAA;EACA,SAAA;;AAGF;EACE,mBAAA;;AADF,MAGE;EACE,YAAA;;AAJJ,MAOE;EACE,WAAA;EACA,YAAA;EACA,iBAAA;EACA,gBAAA;EACA,kBAAA;;AAZJ,MAOE,UAOE;EACE,kBAAA;EACA,YAAA;;AAhBN,MAOE,UAYE;EACE,YAAA;EACA,iBAAA;;AArBN,MAyBE;EACE,qBAAA;EACA,iBAAA;EACA,iBAAA;EACA,kBAAA;;AA7BJ,MAyBE,IAME;EACE,iBAAA;;AAKN;AAAQ;EACN,iBAAA;EACA,cAAA;;AAGF;EACE,WAAA;EACA,UAAA;;AAFF,KAIE;EACE,iBAAA;;AALJ,KAIE,IAGE;EACE,cAAA;EACA,gBAAA;EACA,mBAAA;;AAVN,KAcE;EACE,mBAAA;EACA,kBAAA;EACA,aAAA;;AAIJ;EACE,WAAA;EACA,UAAA;;AAGF;EACE,WAAA;;;;;AAOF,KAAK;AACL,KAAK;AACL,KAAK;AACL,KAAK;AACL;AACA;EACE,WAAA;EACA,cAAA;EACA,YAAA;;AAGF,IAAK;EACH,kBAAA;;AAGF,IAAK;EACH,iBAAA;;AAGF,IAAK,IAAG;EACN,cAAA;;;;;AAOF;AAAQ,CAAC;AAAM,KAAK;EAClB,mBAAA;EACA,YAAA;EACA,uBAAA;EACA,iBAAA;EACA,qBAAA;EACA,cAAA;EACA,kBAAA;;AAEA,MAAC;AAAD,CATO,IASN;AAAD,KATkB,OASjB;EACC,mBAAA;EACA,YAAA;;AAIJ,KAAK;EACH,YAAA;EACA,mBAAA;;AAEA,KAJG,OAIF;EACC,mBAAA;EACA,YAAA;;;;;AAQJ;AACA;EACE,eAAA;EACA,kBAAA;;AAGF;EACE,YAAA;EACA,mBAAA;;AAFF,KAIE;EACE,gCAAA;EACA,uBAAA;EACA,SAAA;EACA,qBAAA;EACA,cAAA;;AATJ,KAIE,GAOE;EACE,YAAA;;AAKN,KAAK,WACH,KAAI;EACF,qBAAA;EACA,gBAAA;EACA,gBAAA;EACA,iBAAA;;AALJ,KAAK,WAQH,KAAI;EACF,aAAA;;AAIJ;EACE,WAAA;EACA,mBAAA;;AAFF,KAIE;AAJF,KAIM;EACF,gBAAA;EACA,gBAAA;;AAIJ;EACE,YAAA;EACA,mBAAA;;AAFF,GAIE,KAAI;EACF,cAAA;;AALJ,GAQE,KAAI;EACF,cAAA;;AATJ,GAYE,KAAI;EACF,cAAA;;AAIJ,KAAK,QACH,GAAE,QACA,GAAE;EACA,cAAA;;AAHN,KAAK,QAOH,GAAE;EACA,6BAAA;;AAIJ,GAAG;EACD,mBAAA;EACA,aAAA;EACA,mBAAA;EACA,kBAAA;;AAJF,GAAG,KAMD;EACE,gBAAA;EACA,iBAAA;;AARJ,GAAG,KAWD;EACE,eAAA;EACA,gBAAA;;AAIJ;EACE,kBAAA;EACA,aAAA;;AAGF,WAAW;EACT,mBAAA;EACA,yBAAA;;AAGF;EACE,SAAA;;AADF,EAGE;EACE,iBAAA;;AAJJ,EAOE;EACE,cAAA;EACA,mBAAA;;;;;AAQJ;EACE,qBAAA;EACA,mBAAA;EACA,YAAA;EACA,sBAAA;;AAJF,KAME;EACE,kBAAA;;AAPJ,KAUE;EACE,YAAA;EACA,uBAAA;;AAIJ;EACE,cAAA;;AAGF;EACE,cAAA","file":"style.css"}
\ No newline at end of file
{"version":3,"sources":["style.less"],"names":[],"mappings":"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","file":"style.css"}
\ No newline at end of file

M pts_central/static/css/style.less => pts_central/static/css/style.less +81 -2
@@ 297,10 297,89 @@ dl {
  }
}

.text-danger {
.text-danger, .result.failed {
  color: #dc3545 !important;
}

.text-success {
.text-success, .result.success {
  color: #28a745 !important;
}

.result.running {
  color: #17a2b8 !important;
}

.result.queued {
  color: #757575 !important;
}

/****************************/
/**  Log                   **/
/****************************/

div.log {
  font-size: 0.9em;

  .block.log {
    background: #0a0c0d;
    color: #f8f9fa;

    table.table-code {
      padding: 0;
      margin: 0;
      width: 100%;
      background: transparent;
      border-collapse: collapse;

      td {
        padding: 0 8px;
      }

      tr {
        td:first-child {
          text-align: right;
          width: 10%;
          border-right: 1px solid #545454;
          vertical-align: top;
        }

        td.controller pre {
          color: #939393;
        }

        td.flasher pre {
          color: lightseagreen;
        }
      }
    }

    pre {
      background: transparent;
      color: #f8f9fa;
      padding: 0;
      margin: 0;
      white-space: pre-wrap;
    }
  }

  h4 {
    padding: 0;
    margin: 0;
    margin-bottom: 10px;
    font-size: 1em;
    text-align: center;
  }

  .block.usb {
    background: @card;
    padding: 15px;
    border-radius: 4px;
    max-width: 80%;
    margin: 0 auto;

    pre {
      padding: 0;
      margin: 0;
    }
  }
}
\ No newline at end of file

M pts_central/templates/base.html => pts_central/templates/base.html +1 -1
@@ 1,7 1,7 @@
<!DOCTYPE html>
<html lang="en">
    <head>
        <title>{% block title %}{% endblock %} - Magma</title>
        <title>{% block title %}{% endblock %} - PTS</title>
        <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
        <link rel="icon" href="{{ url_for('static', filename='logo.svg') }}" type="image/svg+xml">
        <link rel="icon" href="{{ url_for('static', filename='logo.png') }}" type="image/png">

M pts_central/templates/job.html => pts_central/templates/job.html +14 -1
@@ 20,7 20,20 @@
    </aside>
    <section>
        {% for task in job.tasks %}
            {{ task }}
            <div class="card task">
                <h3>
                    Task <a href="{{ url_for('job.view_task', jid=job.id, tid=task.id) }}">#{{ task.id }}</a>
                    <span class="right">{{ task.status.value.title() }}</span>
                </h3>
                {% if task.devicetype_id %}
                    <div>Device type: {{ task.devicetype.name }}</div>
                {% endif %}
                {% if task.device_id %}
                    <div>Device: {{ task.device.name }}</div>
                {% else %}
                    <div>Device: not assigned yet</div>
                {% endif %}
            </div>
        {% else %}
            <div class="no-result">This job has not been started yet</div>
        {% endfor %}

M pts_central/templates/jobs.html => pts_central/templates/jobs.html +19 -1
@@ 22,7 22,25 @@
            <div class="card job">
                <h3>
                    <a href="{{ url_for('job.view', jid=job.id) }}">#{{ job.id }}</a> by ~{{ job.user.username }}
                    <span class="timesince right">{{ job.created | timesince }}</span>
                    <span class="right">
                        <span class="timesince">{{ job.created | timesince }}</span>
                        <span class="result {{ job.status.value }}">
                            {% if job.status.value == "failed" %}
                                <span aria-hidden="true" class="icon icon-times text-danger">
                                <svg viewBox="0 0 352 512" xmlns="http://www.w3.org/2000/svg">
                                    <path d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"></path>
                                </svg>
                            </span>
                            {% elif job.status.value == "success" %}
                                <span aria-hidden="true" class="icon icon-check text-success">
                                <svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
                                    <path d="M173.898 439.404l-166.4-166.4c-9.997-9.997-9.997-26.206 0-36.204l36.203-36.204c9.997-9.998 26.207-9.998 36.204 0L192 312.69 432.095 72.596c9.997-9.997 26.207-9.997 36.204 0l36.203 36.204c9.997 9.997 9.997 26.206 0 36.204l-294.4 294.401c-9.998 9.997-26.207 9.997-36.204-.001z"></path>
                                </svg>
                            </span>
                            {% endif %}
                            {{ job.status.value.title() }}
                        </span>
                    </span>
                </h3>
                {{ job.description }}
            </div>

A pts_central/templates/task.html => pts_central/templates/task.html +85 -0
@@ 0,0 1,85 @@
{% extends "base.html" %}

{% block title %}#{{ job.id }} - Task #{{ task.id }}{% endblock %}

{% block main %}
    <aside>
        <h2>#{{ task.id }}</h2>
        <dl>
            <dt>Job</dt>
            <dd><a href="{{ url_for('job.view', jid=job.id) }}">#{{ job.id }}</a></dd>
            <dt>Device Type</dt>
            <dd>{{ task.devicetype.name }}</dd>
            <dt>Device</dt>
            <dd>{{ task.device.name }}</dd>
            <dt>Owner</dt>
            <dd>~{{ job.user.username }}</dd>
            <dt>Created</dt>
            <dd>{{ job.created | timesince }}</dd>
            <dt>Manifest</dt>
            <dd><a href="{{ url_for('job.manifest', jid=job.id) }}">View manifest »</a></dd>
            <dt>Job attachments</dt>
            <dd>
                {% for artifact in job.artifacts %}
                    <a href="{{ url_for('artifact.get', artifact_id=artifact.id) }}">{{ artifact.name }}</a><br>
                {% else %}
                    No attachments
                {% endfor %}
            </dd>
            <dt>Artifacts</dt>
            <dd>
                {% for artifact in task.artifacts %}
                    <a href="{{ url_for('artifact.get', artifact_id=artifact.id) }}">{{ artifact.name }}</a><br>
                {% else %}
                    No artifacts
                {% endfor %}
            </dd>
        </dl>
        <form method="post" action="{{ url_for('job.submit') }}">
            <input type="hidden" name="job" value="{{ job.manifest }}">
            <button type="submit">Edit & resubmit</button>
        </form>
    </aside>
    <section>
        <div class="log">
            {% for section in log %}
                <details class="section" open>
                    <summary>{{ section.section }}</summary>
                    {% for block in section.blocks %}
                        <div class="block {{ block.block }}">
                            {% if block.block == 'log' %}
                                <table class="table-code">
                                    {% for event in block.events %}
                                        <tr>
                                            <td class="{{ event.c }}">
                                                <pre>{{ event.c }}</pre>
                                            </td>
                                            <td>
                                                <pre>{{ event.l }}</pre>
                                            </td>
                                        </tr>
                                    {% endfor %}
                                </table>
                            {% elif block.block == 'usb' %}
                                {% for event in block.events %}
                                    <div>
                                        {% if 'ui' in event %}
                                            <h4>New USB device</h4>
                                            <pre>{{ event.ui.vid }}:{{ event.ui.pid }} {{ event.ui.manufacturer }} {{ event.ui.product }}</pre>
                                            <pre>Class {{ event.ui.device_class }} SubClass {{ event.ui.device_subclass }} Protocol {{ event.ui.device_protocol }}</pre>
                                            {% for intf in event.ui.interfaces %}
                                                <pre>  interface {{ intf.number }}: {{ intf.interface_class }}/{{ intf.interface_subclass }}/{{ intf.interface_protocol }} {{ intf.interface or '' }}</pre>
                                            {% endfor %}
                                        {% else %}
                                            <h4>USB disconnected</h4>
                                        {% endif %}
                                    </div>
                                {% endfor %}
                            {% endif %}
                        </div>
                    {% endfor %}
                </details>
            {% endfor %}
        </div>
    </section>
{% endblock %}
\ No newline at end of file