NAME

Kubernetes::Comb::SVG - Render Kubernetes::Comb custom resources as an SVG honeycomb

VERSION

version 0.001

SYNOPSIS

use Kubernetes::Comb::SVG;

my $svg = Kubernetes::Comb::SVG->new(
  combs       => \@combs,
  title       => 'Lab',
  group_label => 'app.kubernetes.io/part-of',
  columns     => 4,
  link        => sub { '/combs/'.$_[0]->name },
  theme       => { Running => '#2da44e', bg => { light => '#fff', dark => '#000' } },
  blink       => ['Error']
)->render;

# A status monitor for a wall screen: packed, shaped for a 16:9 display
my $monitor = Kubernetes::Comb::SVG->new(
  combs  => \@combs,
  layout => 'packed',
  aspect => 16 / 9,
  blink  => [ 'Error', 'Blocked' ]
)->render;

# @combs: hashes in CR shape, e.g.
#   { metadata => { name => 'db', namespace => 'lab' },
#     spec     => { class => 'postgres' },
#     status   => { phase => 'Running' } }
#   { metadata => { name => 'nats' }, spec => { dependsOn => ['db'] } }

DESCRIPTION

Draws a set of Kubernetes::Comb custom resources as one self-contained SVG document: a honeycomb with one hexagon per Comb, coloured by its phase, with the dependencies drawn between them and a legend of the phases that occur. The Combs are placed by dependency depth, or, as a status monitor, packed into one compact honeycomb, see "layout"; the colours follow the light or dark mode of the viewer and can be set by option or by the embedding page, see "theme" and "THE PICTURE". Data in, string out: the dist never talks to a cluster -- the caller fetches the custom resources (kubectl get combs -A -o json, a client library, a fixture) and hands them in. Kubernetes::Comb and IO::K8s are not dependencies; the input is duck-typed, see "combs".

The same input gives the same bytes: no timestamps, no generated ids, every hash sorted before it reaches the output. The picture carries no script and no reference to anything outside the document, and everything that comes from a custom resource is escaped, see "THE PICTURE".

Odd data never dies: an unknown phase is drawn as Unknown, a missing status is fine, a dependency on a name that is not in the input is listed as missing in the tooltip, a dependency cycle puts its cells on one row. Only a Comb without metadata.name is an error.

examples/demo.pl in the distribution renders examples/demo.json to examples/demo.svg, the picture the README shows. The command line equivalent is comb-svg.

Honeycomb of seventeen Combs in two groups, coloured by phase, with dependency edges

The same Combs in the packed layout, the status monitor

combs

Required. The custom resources to draw, in any of these shapes:

  • an array reference of Comb custom resources

  • a List hash, a hash with items, as kubectl get combs -o json prints it

  • a single custom resource (a hash without items)

Each custom resource is a plain hash in CR shape (metadata, spec, status) or an object answering TO_JSON with such a hash, like the IO::K8s classes of Kubernetes::Comb. undef and an empty list give a valid picture with no cells.

combs => [ { metadata => { name => 'db' } }, $comb_object ]
combs => { items => \@combs }

Of two custom resources with the same namespace/name the first is kept. The input is read when the picture is first needed ("cells", "render"), not by the constructor; a Comb without metadata.name dies there. See "cells_from" in Kubernetes::Comb::SVG::Cell for what is read from each one.

title

Default Combs. The text of the heading above the honeycomb and of the <title> of the SVG (what a screen reader announces). The canvas widens if a long title needs it.

group_label

Optional, no default. A label key, for example app.kubernetes.io/part-of: the Combs are grouped by the value that label has in metadata.labels. Each group is drawn under its own heading, groups stacked top to bottom in name order, the Combs without the label in a last group without a name. Without it there is one group and no headings. No label key is built in; which one groups your Combs is your choice.

layout

Default depth: inside a group a Comb sits one row below its deepest dependency. packed is the status monitor for a wall screen: dependencies play no part in placement, the Combs are sorted by namespace/name and fill the rows left to right, top to bottom, as one compact honeycomb (one per group with "group_label"). A Comb keeps its place as long as the set of Combs is the same; a phase changing moves nothing. The grid of packed comes from "columns" when given, else from "rows", else from "aspect", and "edges" defaults to false there.

columns

Default 6, a positive integer. How many hexagons a row holds before it wraps into the next row. Wrapped rows stay in the group and in the dependency depth they belong to. In the packed "layout" it is the cells per row and counts only when given: the default leaves the grid to "rows" and "aspect".

rows

Optional, a positive integer, no default. packed "layout" only, and only when "columns" is not given: the number of rows of a block; the columns follow from the number of Combs. A block has fewer rows when its Combs do not fill them, and with "group_label" it holds for every group on its own.

aspect

Default 16/9, a positive number. packed "layout" only, and only when neither "columns" nor "rows" is given: width divided by height of the area the picture is to fill, 9/16 for an upright screen. The column count is the one whose picture -- all groups with their headings, plus padding, title and a legend of one row -- comes closest to that shape. A long title or a legend that wraps is not accounted for.

size

Default 56, a positive number. The radius of a hexagon, centre to corner, in SVG units. Fonts, gaps, stroke widths and the padding all derive from it, so the picture changes scale as a whole. The SVG has a viewBox and no fixed pixel size: it scales with the box that embeds it anyway.

edges

Default true, false in the packed "layout"; a given value wins in both. Draws one arrow per dependency, from the dependent Comb to the Comb it depends on. False leaves out the g.deps group; the placement of the cells does not change.

legend

Default true. Draws the legend below the honeycomb: one entry for each phase that occurs, with its colour and the number of Combs in it. False leaves out the g.legend group.

Optional coderef, no default. Called once per cell with its Kubernetes::Comb::SVG::Cell object; it returns the URL the cell links to, or undef for no link. A linked cell is wrapped in <a href="...">.

link => sub { my ( $cell ) = @_; '/combs/'.$cell->namespace.'/'.$cell->name }

The result is used only when it is relative (/combs/db, db.html) or starts with http:// or https://, and carries no whitespace or control character; anything else (javascript:, data:, a reference, an empty string) draws the cell without a link. The value is escaped as an attribute. A callback that dies is caught: the cell is drawn without a link and a warning ("carp" in Carp) names it.

theme

Default {}. A hash from key to colour, merged over the built-in colours. The keys are the phase names (Running, Pending, Blocked, NeedsConfig, Disabled, Error, Stopped, NotDeployed, Unknown, see "phases") and the surfaces of the picture: bg (the panel), fg (text), muted (secondary text), border (panel outline and group rules) and edge (dependency edges). Keys are case-sensitive; any other key is ignored.

theme => {
  Running => '#2da44e',
  Error   => { light => 'crimson', dark => '#ff6b6b' },
  bg      => { light => '#ffffff', dark => '#000000' }
}

A value is one colour, used in light and in dark mode, or a hash with light and dark; a mode the hash leaves out keeps its built-in colour. The colour of a phase is the outline of the hexagon; its fill is the same colour at low opacity, so the text stays readable whatever colour is chosen. Accepted are #rgb, #rgba, #rrggbb, #rrggbbaa, a colour name (letters only) and rgb(), rgba(), hsl(), hsla() over plain numbers; any other value falls back to the built-in colour, for each mode on its own. Every colour ends up as a custom property, see "THE PICTURE".

The built-in colours, light / dark:

Running      #1a7f37 / #3fb950      bg      #ffffff / #0d1117
Pending      #bf8700 / #e3b341      fg      #1f2328 / #e6edf3
Blocked      #bc4c00 / #fb8f44      muted   #59636e / #9198a1
NeedsConfig  #8250df / #a371f7      border  #d0d7de / #30363d
Disabled     #8c959f / #6e7681      edge    #57606a / #9198a1
Error        #cf222e / #f85149
Stopped      #0891b2 / #39c5cf
NotDeployed  #0969da / #58a6ff
Unknown      #475569 / #94a3b8

Default []. The phases (see "phases", case-sensitive) whose cells pulse, for a screen on which an Error has to catch the eye:

blink => [ 'Error', 'Blocked' ]

The pulse is a CSS animation in the <style> of the picture, no script: fill and outline of the hexagon swell and settle, the texts stay as they are. Where the viewer asks for reduced motion nothing moves and the cell has a thicker outline instead. A name that is no phase is ignored; order and repeats do not matter. Without it the picture carries no animation at all.

Default 1.2, a positive number. The period of the pulse of "blink" in seconds, there and back. Written with two decimals, 0.01 at the least.

cells

The Kubernetes::Comb::SVG::Cell objects read from "combs", in input order. Built on first use. Not a constructor argument.

cell_class

Returns the class name that reads the custom resources, Kubernetes::Comb::SVG::Cell. It must answer cells_from, known_phases and the cell accessors. Override in a subclass to read the custom resources differently.

layout_class

Returns the class name that places the cells, Kubernetes::Comb::SVG::Layout. It is built with cells, size, mode (the "layout"), aspect, frame_width, frame_height and, when given, columns and rows, and must answer layout and the geometry methods of Kubernetes::Comb::SVG::Layout. Override in a subclass to place the cells differently.

phases

my @phases = $svg->phases;

Returns the phases a cell can be drawn in, in the fixed order of the legend: Running, Pending, Blocked, NeedsConfig, Disabled, Error, Stopped, NotDeployed, then Unknown for every other status.phase (and for a missing one). These are the keys "theme" understands.

render

my $svg = $svg->render;

Returns the picture as one SVG document in a string: it starts with <svg (no XML declaration, so it can be inlined into HTML as well as served as image/svg+xml) and ends with a newline. The string is pure ASCII: what is outside ASCII is written as a character reference. Same input and options, same bytes. The document is self-contained, see "THE PICTURE".

Dies with Comb without metadata.name when an element of "combs" has no name; nothing else in the data is an error. The constructor dies, as Moo does, on an option of the wrong type. A dying "link" callback only warns.

THE PICTURE

What the document contains, so a page that embeds it can style or script against it. Everything is plain SVG; a page can query it with the DOM when the SVG is inlined.

Where the cells sit depends on "layout". With depth a cell is in the row of its dependency depth, so what has to be up first is above. With packed the cells are sorted by namespace/name and fill one compact honeycomb (one per group) whatever they depend on; the dependency edges are then left out unless "edges" is true. The elements below are the same in both.

  • The root is <svg class="comb-svg" role="img"> with xmlns, a viewBox and no fixed width or height, labelled by <title id="comb-title"> (from "title") and <desc id="comb-desc">, a generated summary such as 3 Combs: 2 Running, 1 Blocked.

  • One <g class="comb phase-Running"> per cell. The class is comb, phase-<Phase> (see "phases"), plus borrowed when the Comb really takes its service from an upstream layer (dashed outline, a line naming the upstream context; see borrowed: an upstream is recorded, it is not unreachable, and the phase is Running or Pending) and disabled for a Disabled one. Attributes: data-name (metadata.name), data-id (namespace/name, or the name alone), data-phase. With "link" the group sits inside an <a>. Inside, in this order:

    • <title>, the tooltip: id, namespace, class, phase, the message when the phase is not Running, endpoints, upstream, missing dependencies. The upstream line is there for every Comb that records one: upstream: <class>, context <context>, either part alone when the other is absent, upstream: recorded with neither, followed by (not borrowing) when the cell is not borrowed; a via: line follows when the upstream names a chain. The tooltip also carries the full name of a cell whose drawn name had to be cut.

    • polygon.hex, the hexagon.

    • text.name, the name. A name that fits stays on one line. One that does not is broken into two after a hyphen, a dot or an underscore, each line a <tspan> inside text.name, at the break that leaves the shortest longer line. When the two lines are still too wide the element has the class name-small as well and a smaller font (13 characters a line instead of 11). Only what fits neither way is cut with an ellipsis, as is a too long name without such a character.

    • text.phase, the phase, always written as text, never by colour alone.

    • text.reason, only for a Comb that is not Running and says why: the reason of the cell in small text, 16 characters a line. It is what a wall screen shows in place of the tooltip; a Running cell never has it. A reason too long for one line is broken into two, each a <tspan> inside text.reason: at a run of whitespace, which is dropped, or before an upper-case letter that follows a lower-case letter or a digit (Missing / Prerequisites), at the break that leaves the shortest longer line. When no break makes both lines fit, the last one whose first line fits is taken and the second line is cut with an ellipsis; a reason without such a break is cut on its one line. The six-line exception: a cell with no room for a sixth line of text -- a name on two lines, the phase, two reason lines and an upstream line -- cuts a too long reason on one line as well. Under a name on two lines the second reason line sits where the hexagon narrows and holds 14 characters, 15 under a name in the smaller font.

    • text.upstream, only for a borrowed Comb: from <context>, or borrowed when no context is recorded.

    A cell with a reason line and more than three lines of text sets them closer together, the phase a little further from the name than the name lines are from each other.

  • g.deps holds one path.dep per dependency, with data-from (the dependent) and data-to (the dependency) as ids, an arrowhead at the dependency, and a circle.dep-start marking where it leaves the dependent. It is drawn before the cells, so cells stay on top; it is absent with edges => 0 and when there are no edges.

  • g.group per group heading, with data-group (the label value) unless it is the group without a name; holds text.group-name and a faint line.group-rule. Only present when the picture has headings, see "group_label".

  • g.legend holds one g.legend-item.phase-<Phase> per phase that occurs, with data-phase and data-count.

  • One <style> element. Colours are CSS custom properties on .comb-svg, see "Styling from outside". A @media (prefers-color-scheme: dark) block gives the dark values. The font is the system sans stack.

  • With "blink", the same <style> holds @keyframes comb-blink, an animation named comb-blink on .comb.phase-<Phase> .hex for each blinking phase, and a @media (prefers-reduced-motion: reduce) block that turns the animation off and thickens the outline. Without "blink" none of this is in the document.

Styling from outside

When the SVG is inlined into a page, the CSS of that page can restyle it. The custom property names, the class names and the animation name are public interface; the rest of the markup is not promised to stay as it is.

Custom properties, set on .comb-svg (light values), and again inside @media (prefers-color-scheme: dark) (dark values):

  • --comb-bg, --comb-fg, --comb-muted, --comb-border, --comb-edge: the panel, the text, secondary text (group names, phase, reason and upstream line, legend), the outline of the panel and the group rules, and the dependency edges

  • --comb-running, --comb-pending, --comb-blocked, --comb-needsconfig, --comb-disabled, --comb-error, --comb-stopped, --comb-notdeployed, --comb-unknown: the colour of each phase (the name is --comb- and the lower-case phase); it is the outline of the hexagon and, at low opacity, its fill

  • --comb-tint and --comb-tint-muted: the fill opacity of a hexagon and of a Disabled one

Classes: .comb per cell with .phase-<Phase>, .borrowed and .disabled (the .phase-<Phase> class is also on the entries of the legend); inside it .hex, .name (with .name-small for a name on two lines in the smaller font; its rule is in the <style> only when a cell needs it), .phase, .reason (its rule, too, is there only when a cell has a reason line) and .upstream. Around them .panel, .heading, .group, .group-name and .group-rule; .deps with .dep, .arrow and .dep-start; .legend with .legend-item and .count. The animation is named comb-blink.

The built-in custom properties sit on .comb-svg itself, and the <style> is part of the document, so whether a page rule with the same selector wins depends on which comes later. Use a more specific selector, such as svg.comb-svg, to win regardless. The dark block is a rule of its own: a colour a page sets that way holds in dark mode too, unless the page sets the dark one in its own media query.

/* in the CSS of the embedding page */
svg.comb-svg {
  --comb-error: #ff0033;
  --comb-bg: #fafafa;
}
@media (prefers-color-scheme: dark) {
  svg.comb-svg { --comb-error: #ff6680; --comb-bg: #101010 }
}
/* dim the Combs that are fine */
svg.comb-svg .comb.phase-Running { opacity: .6 }
/* a different pulse: override animation on the hexagon of a blinking phase */
svg.comb-svg .comb.phase-Error .hex { animation-duration: .6s }

Setting "theme" instead needs no page CSS: it writes the same properties.

The picture is self-contained: no script, no web font, no stylesheet link, no image, no reference to anything outside the document (the arrowhead is a <marker> inside it). Every value that comes from a custom resource -- names, namespaces, messages, reasons, label values, upstream classes and contexts, a "link" result -- is escaped wherever it lands, in text, in <title> and in attributes, and the five XML special characters become entities. Characters XML 1.0 cannot carry are dropped, and everything outside ASCII becomes a numeric character reference. A Comb named </svg><script> comes out as text.

SEE ALSO

SUPPORT

Issues

Please report bugs and feature requests on GitHub at https://github.com/Getty/p5-kubernetes-comb-svg/issues.

IRC

Join #kubernetes on irc.perl.org or message Getty directly.

CONTRIBUTING

Contributions are welcome! Please fork the repository and submit a pull request.

AUTHOR

Torsten Raudssus <getty@cpan.org>

COPYRIGHT AND LICENSE

This software is copyright (c) 2026 by Torsten Raudssus <torsten@raudssus.de> https://raudssus.de/.

This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.