Skip to content

Commit

Permalink
feat: implement modular V2 scan visualizer components
Browse files Browse the repository at this point in the history
Added a new modular implementation for the V2 scan visualizer by introducing reusable components and templates.
  • Loading branch information
tymees committed Jan 27, 2025
1 parent 74c86c7 commit 088d103
Show file tree
Hide file tree
Showing 18 changed files with 1,105 additions and 78 deletions.
10 changes: 6 additions & 4 deletions humitifier-server/src/hosts/scan_visualizers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
from .base_visualizer import ScanVisualizer
from .v1 import V1ScanVisualizer
from .v2 import V2ScanVisualizer
from ..models import ScanData
from ..models import Host, ScanData


def get_scan_visualizer(scan_data: ScanData) -> ScanVisualizer:
def get_scan_visualizer(
host: Host, scan_data: ScanData, context: dict
) -> ScanVisualizer:
match scan_data.version:
case 1:
return V1ScanVisualizer(scan_data)
return V1ScanVisualizer(host, scan_data, context)
case _:
return V2ScanVisualizer(scan_data)
return V2ScanVisualizer(host, scan_data, context)
147 changes: 147 additions & 0 deletions humitifier-server/src/hosts/scan_visualizers/base_components.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import dataclasses
from datetime import datetime
from typing import TypeVar

from django.template.loader import render_to_string
from django.utils.functional import cached_property
from django.utils.safestring import mark_safe

from humitifier_common.artefacts.registry.registry import ArtefactType

T = TypeVar("T")


class ArtefactVisualizer:
artefact: type[T] = None
title: str | None = None
template: str = "hosts/scan_visualizer/components/base_component.html"

def __init__(self, artefact_data: T, scan_date: datetime):
self.artefact_data = artefact_data
self.scan_date = scan_date

def show(self):
return self.artefact_data is not None

def get_context(self, **kwargs) -> dict:
kwargs["title"] = self.title
kwargs["is_metric"] = self.artefact.__artefact_type__ == ArtefactType.METRIC
kwargs["alpinejs_settings"] = {}

return kwargs

def render(self) -> str | None:
return render_to_string(self.template, context=self.get_context())


class ItemizedArtefactVisualizer(ArtefactVisualizer):
template = "hosts/scan_visualizer/components/itemized_component.html"
attributes: dict[str, str] | None = None

def get_items(self) -> list[dict[str, str]]:
data = []
for item, label in self.attributes.items():
value = self.get_attribute_value(item)
data.append(
{
"label": label,
"value": value,
}
)

return data

def get_context(self, **kwargs) -> dict:
context = super().get_context(**kwargs)

context["data"] = self.get_items()

return context

def get_attribute_value(self, item):
value = getattr(self.artefact_data, item, None)

if value and hasattr(self, f"get_{item}_display"):
actual_value = getattr(self, f"get_{item}_display")(value)
if actual_value is not None:
return mark_safe(actual_value)

return actual_value

return value


@dataclasses.dataclass
class Bar:
label_1: str
label_2: str | None = None
used: str | None = None
total: str | None = None
percentage: float | None = None


class BarsArtefactVisualizer(ArtefactVisualizer):
template = "hosts/scan_visualizer/components/bars_component.html"

def get_context(self, **kwargs) -> dict:
context = super().get_context(**kwargs)

context["data"] = self.get_bar_items()

return context

def get_bar_items(self) -> list[Bar]:
raise NotImplementedError()


@dataclasses.dataclass
class Card:
"""
Dataclass to represent a card.
Title will be displayed promenently as the header
Aside will be displayed next to the title, for secondary info
Content can be specified in one of two ways, either as a string
which will just be 'pasted' as the content, or a dict of key-values
which will be displayed as a nicely formatted list. (Like ItemizedArtefactVisualizer)
Search_value should be filled with a string that will be used to search through.
(Using a simple 'string includes search-text' method).
If multiple elements should be searched at the same time, you should just concat
them inside the string ;)
"""

title: str | None = None
aside: str | None = None
content: str | None = None
content_items: dict[str, str] | None = None
search_value: str | None = None


class SearchableCardsVisualizer(ArtefactVisualizer):
template = "hosts/scan_visualizer/components/searchable_cards_component.html"
search_placeholder = "Search"
allow_search = True
min_items_for_search = 3

def get_items(self) -> list[Card]:
raise NotImplementedError()

@cached_property
def _items(self):
return self.get_items()

def show_search(self):
return self.allow_search and len(self._items) > self.min_items_for_search

def get_context(self, **kwargs) -> dict:
context = super().get_context(**kwargs)

context["data"] = self._items
context["show_search"] = self.show_search
context["search_placeholder"] = self.search_placeholder

context["alpinejs_settings"]["search"] = "''"

return context
73 changes: 70 additions & 3 deletions humitifier-server/src/hosts/scan_visualizers/base_visualizer.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
from hosts.models import ScanData
from django.template.loader import render_to_string

from hosts.models import Host, ScanData
from hosts.scan_visualizers.base_components import ArtefactVisualizer
from humitifier_common.artefacts.registry.registry import ArtefactType


class ScanVisualizer:

def __init__(self, scan_data: ScanData):
def __init__(self, host: Host, scan_data: ScanData, context: dict):
self.host = host
self.scan_data = scan_data
self.request = None
self.provided_context = context

def get_context(self, **kwargs):

kwargs.update(
{
"current_scan_date": self.scan_data.scan_date,
Expand All @@ -18,10 +23,72 @@ def get_context(self, **kwargs):
else self.scan_data.raw_data
),
"raw_data": self.scan_data.raw_data,
"host": self.host,
}
)

kwargs.update(self.provided_context)

return kwargs

def render(self):
raise NotImplementedError()


class ComponentScanVisualizer(ScanVisualizer):
template = None

visualizers: list[type[ArtefactVisualizer]] = []

def render(self):
return render_to_string(
self.template,
context=self.get_context(),
request=self.request,
)

def get_artefact_data(self, artefact):
artefact_name = artefact.__artefact_name__
artefact_type: ArtefactType = artefact.__artefact_type__

if artefact_type == ArtefactType.FACT:
return self.scan_data.parsed_data.facts.get(artefact_name, None)
else:
return self.scan_data.parsed_data.metrics.get(artefact_name, None)

def render_components(
self, components: list[type[ArtefactVisualizer]]
) -> dict[str, str]:
output = {}
for component in components:
data = self.get_artefact_data(component.artefact)
if data:
cmp = component(data, self.scan_data.scan_date)
if cmp.show():
output[component.title] = cmp.render()

return output

@property
def fact_visualizers(self):
return [
visualizer
for visualizer in self.visualizers
if visualizer.artefact.__artefact_type__ == ArtefactType.FACT
]

@property
def metric_visualizers(self):
return [
visualizer
for visualizer in self.visualizers
if visualizer.artefact.__artefact_type__ == ArtefactType.METRIC
]

def get_context(self, **kwargs):
context = super().get_context(**kwargs)

context["facts"] = self.render_components(self.fact_visualizers)
context["metrics"] = self.render_components(self.metric_visualizers)

return context
Original file line number Diff line number Diff line change
@@ -1 +1 @@
from .visualizer import V2ScanVisualizer
from .scan_visualizer import V2ScanVisualizer
Loading

0 comments on commit 088d103

Please sign in to comment.