diff --git a/airlock/renderers.py b/airlock/renderers.py
index 8faa73cc..60407a90 100644
--- a/airlock/renderers.py
+++ b/airlock/renderers.py
@@ -2,6 +2,7 @@
import csv
import mimetypes
+import re
from dataclasses import dataclass
from email.utils import formatdate
from functools import cached_property
@@ -9,9 +10,11 @@
from pathlib import Path
from typing import IO, Any, ClassVar, Self, cast
+from ansi2html import Ansi2HTMLConverter
from django.http import FileResponse, HttpResponseBase
from django.template import Template, loader
from django.template.response import SimpleTemplateResponse
+from django.utils.safestring import mark_safe
from airlock.types import UrlPath
from airlock.utils import is_valid_file_type
@@ -158,9 +161,35 @@ def context(self):
}
+class LogRenderer(TextRenderer):
+ def context(self):
+ # Convert the text of the log file to HTML, converting ANSI colour codes to css classes
+ # so we get the colour formatting from the original log.
+ # We don't need the full HTML file that's produced, so just extract the
+ # tag which contains the log content and the inline styles.
+ conv = Ansi2HTMLConverter()
+ text = conv.convert(self.stream.read())
+ match = re.match(
+ r".*(?P).*(?P).*",
+ text,
+ flags=re.S,
+ )
+ if match: # pragma: no branch
+ # After conversion, we should always find a match. As a precaution, check
+ # and render the plain text if we don't.
+ style_tag = match.group("style")
+ pre_tag = match.group("pre_tag")
+ text = mark_safe(f"{style_tag}{pre_tag}")
+
+ return {
+ "text": text,
+ "class": Path(self.filename).suffix.lstrip("."),
+ }
+
+
FILE_RENDERERS = {
".csv": CSVRenderer,
- ".log": TextRenderer,
+ ".log": LogRenderer,
".txt": TextRenderer,
".json": TextRenderer,
".md": TextRenderer,
diff --git a/requirements.prod.in b/requirements.prod.in
index 80a47b1f..c092513a 100644
--- a/requirements.prod.in
+++ b/requirements.prod.in
@@ -1,3 +1,4 @@
+ansi2html
Django
django-vite
slippers
diff --git a/requirements.prod.txt b/requirements.prod.txt
index 6935e226..9fede944 100644
--- a/requirements.prod.txt
+++ b/requirements.prod.txt
@@ -4,6 +4,10 @@
#
# pip-compile --allow-unsafe --generate-hashes --strip-extras requirements.prod.in
#
+ansi2html==1.9.2 \
+ --hash=sha256:3453bf87535d37b827b05245faaa756dbab4ec3d69925e352b6319c3c955c0a5 \
+ --hash=sha256:dccb75aa95fb018e5d299be2b45f802952377abfdce0504c17a6ee6ef0a420c5
+ # via -r requirements.prod.in
asgiref==3.8.1 \
--hash=sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47 \
--hash=sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590
diff --git a/tests/unit/test_renderers.py b/tests/unit/test_renderers.py
index 0d813e06..e4c7dcb4 100644
--- a/tests/unit/test_renderers.py
+++ b/tests/unit/test_renderers.py
@@ -12,10 +12,12 @@
(".png", "image/png", False, None),
(".csv", "text/html", False, "airlock/templates/file_browser/csv.html"),
(".txt", "text/html", False, "airlock/templates/file_browser/text.html"),
+ (".log", "text/html", False, "airlock/templates/file_browser/text.html"),
(".html", "text/html", True, "airlock/templates/file_browser/plaintext.html"),
(".png", "text/html", True, "airlock/templates/file_browser/plaintext.html"),
(".csv", "text/html", True, "airlock/templates/file_browser/plaintext.html"),
(".txt", "text/html", True, "airlock/templates/file_browser/plaintext.html"),
+ (".log", "text/html", True, "airlock/templates/file_browser/plaintext.html"),
]
@@ -145,3 +147,27 @@ def test_plaintext_renderer_handles_invalid_utf8(tmp_path):
response.render()
assert response.status_code == 200
assert "invalid � continuation byte" in response.rendered_content
+
+
+def test_log_renderer_handles_ansi_colors(tmp_path):
+ log_file = tmp_path / "test.log"
+ # in ansi codes:
+ # \x1B[32m = foregrouund green
+ # \x1b[1m bold
+ # \x1b[0m resets formatting
+ log_file.write_bytes(
+ b"No ansi here \x1b[32m\x1b[1mThis is green and bold.\x1b[0m This is not."
+ )
+ relpath = log_file.relative_to(tmp_path)
+ Renderer = renderers.get_renderer(relpath)
+ renderer = Renderer.from_file(log_file, relpath)
+ response = renderer.get_response()
+ response.render()
+ assert response.status_code == 200
+
+ assert "ansi1 { font-weight: bold; }" in response.rendered_content
+ assert "ansi32 { color: #00aa00; }" in response.rendered_content
+ assert (
+ 'This is green and bold.'
+ in response.rendered_content
+ )