From dbceda65f6a492b02bae78525807e62a1b455b5d Mon Sep 17 00:00:00 2001 From: Roger Binns Date: Fri, 29 Nov 2024 17:17:19 -0800 Subject: [PATCH] Tidied up, extra fields added, ready to move to apsw.ext --- makesvg.py | 345 +++++++++++++++++++++++++++-------------------------- 1 file changed, 179 insertions(+), 166 deletions(-) diff --git a/makesvg.py b/makesvg.py index 48f1e5ba..bbde2d54 100644 --- a/makesvg.py +++ b/makesvg.py @@ -3,111 +3,11 @@ import apsw, pprint, sys, apsw.ext, math, apsw.shell, os, html, math from fractions import Fraction +from typing import TextIO -con = apsw.Connection(sys.argv[1]) - - -root, group, each = (apsw.ext.analyze_pages(con, n) for n in range(3)) - -if False: - pprint.pprint(root) - pprint.pprint(group) - -# angles are 0.0 through 1.0 -# distance (radius) is 0 through 1.0 - -# use this as radius for output coordinates as int -RADIUS = 1000 - - -def colour_for_angle(angle: float) -> str: - # these are zero to one and cover how dark to light the colours - # become. base is added to each rgb, while span is how much - # the values range above that. - base = 0 - span = 1 - assert base + span <= 1 - - radians = angle * math.pi - third = 1 / 3 * math.pi - - red = int(255 * (base + span * abs(math.cos(radians)))) - green = int(255 * (base + span * abs(math.cos(third + radians)))) - blue = int(255 * (base + span * abs(math.cos(third + third + radians)))) - - return f"#{red:02x}{green:02x}{blue:02x}" - - -def pos_for_angle(angle: float, distance: float) -> tuple[float, float]: - "give x,y for distance from centre" - - # the minus bit is because trig has east as 0 but we want north as - # zero - radians = angle * 2 * math.pi - (1 / 4 * 2 * math.pi) - - return distance * math.cos(radians), distance * math.sin(radians) - - -def c(v: float | list[float]) -> str: - # outputs a coordinate scaling by RADIUS - if isinstance(v, float): - return str(round(v * RADIUS)) - return " ".join(str(round(x * RADIUS)) for x in v) - - -def p(angle, distance): - return c(pos_for_angle(angle, distance)) - -def slice(id: str, start_angle: float, end_angle: float, start_distance: float, end_distance: float): - assert 0 <= start_angle <= 1.0 - assert 0 <= end_angle <= 1.0 - assert end_angle > start_angle - assert 0 <= start_distance <= 1.0 - assert 0 <= end_distance <= 1.0 - assert end_distance > start_distance - - l = 1 if (end_angle - start_angle) > 1 / 2 else 0 - - d = [] - d.append(f"M {p(start_angle, start_distance)}") - d.append(f"L {p(start_angle, end_distance)}") - d.append(f"A {c(end_distance)} {c(end_distance)} 0 {l} 1 {p(end_angle, end_distance)}") - d.append(f"L {p(end_angle, start_distance)}") - d.append(f"A {c(start_distance)} {c(start_distance)} 0 {l} 0 {p(start_angle, start_distance)}") - - ds = " ".join(d) - - fill = colour_for_angle((start_angle + end_angle) / 2) - return f"""""" - - -def text(pos: tuple[float, float], id: str, name: str, ring: int, usage) -> str: - x, y = c(pos[0]), c(pos[1]) - e = html.escape - res = f"""""" - res += f"""{e(name)}""" - if ring == 0: - assert isinstance(usage, apsw.ext.DatabasePageUsage) - total = storage(usage.pages_total * usage.page_size) - used = storage(usage.pages_used * usage.page_size) - res += f"""{used} / {total}""" - res += f"""{len(usage.tables):,} tables""" - res += f"""{len(usage.indices):,} indices""" - else: - if ring == 2: - kind = "table" if usage.tables else "index" - res += f"""({kind})""" - size = storage(usage.pages_used * usage.page_size) - res += f"""{size}""" - if ring == 1: - res += f"""{len(usage.tables):,} tables""" - res += f"""{len(usage.indices):,} indices""" - res += """""" - return res - - -def storage(v): +def storage(v) -> str: + """Converts number to storage size (KB, MB, GB etc)""" if not v: return "0" power = math.floor(math.log(v, 1024)) @@ -117,72 +17,185 @@ def storage(v): return f"{v / 1024**power:.1f}".rstrip(".0") + suffix -# controls how much whitespace is around the edges -OVERSCAN = 1.05 -header = f"""""" -footer = "" - -# maps which element hovering over causes a response on -hover_response: dict[str, str] = {} - -id_counter = 0 - - -def next_id(): - global id_counter - id_counter += 1 - return f"id{id_counter}" - - -out = [header] -# z-order is based on output order so texts go last -texts: list[str] = [] +def page_usage_to_svg(con: apsw.Connection, out: TextIO, schema: str = "main") -> None: + """Visualize database space usage as a `SVG `__ + + You can hover or click on segments to get more details. The + centre circle shows information about the database as a whole, the + middle ring shows usage grouped by database (combing indices, + shadow tables for virtual tables), while the outer ring shows each + index and table separately. + + Uses :func:`analyze_pages` to gather the information. + + :param con: Connection to query + :param out: Where the svg is written to. You can use + :class:`io.StringIO` if you want it as a string. + :param schema: Which attached database to query + """ + # Angles and distances are used within. They are in the range 0.0 + # to 1.0 . + + # Coordinates are output as int, so this is the scaling factor + RADIUS = 1000 + # how much whitespace is outside the circles + OVERSCAN = 1.05 + + def colour_for_angle(angle: float) -> str: + # we use r g b each offset by a third of the circle + radians = angle * math.pi + third = 1 / 3 * math.pi + + red = int(255 * abs(math.cos(radians))) + green = int(255 * abs(math.cos(third + radians))) + blue = int(255 * abs(math.cos(third + third + radians))) + + return f"#{red:02x}{green:02x}{blue:02x}" + + def pos_for_angle(angle: float, distance: float) -> tuple[float, float]: + "give x,y for distance from centre" + + # the minus bit is because trig has east as 0 but we want north as + # zero + radians = angle * 2 * math.pi - (1 / 4 * 2 * math.pi) + + return distance * math.cos(radians), distance * math.sin(radians) + + # these two are used in fstrings hence the short names + def c(v: float | list[float]) -> str: + # outputs a coordinate scaling by RADIUS + if isinstance(v, float): + return str(round(v * RADIUS)) + return " ".join(str(round(x * RADIUS)) for x in v) + + def p(angle: float, distance: float): + # outputs a coordinate scaling by RADIUS + return c(pos_for_angle(angle, distance)) + + def slice(id: str, start_angle: float, end_angle: float, start_distance: float, end_distance: float): + # produces one of the circular slices + large = 1 if (end_angle - start_angle) > 1 / 2 else 0 + + d = [] + d.append(f"M {p(start_angle, start_distance)}") + d.append(f"L {p(start_angle, end_distance)}") + d.append(f"A {c(end_distance)} {c(end_distance)} 0 {large} 1 {p(end_angle, end_distance)}") + d.append(f"L {p(end_angle, start_distance)}") + d.append(f"A {c(start_distance)} {c(start_distance)} 0 {large} 0 {p(start_angle, start_distance)}") + + ds = " ".join(d) + + fill = colour_for_angle((start_angle + end_angle) / 2) + return f"""""" + + def text(pos: tuple[float, float], id: str, name: str, ring: int, usage: DatabasePageUsage | PageUsage) -> str: + # produces text infobox + x, y = c(pos[0]), c(pos[1]) + e = html.escape + res = [] + res.append(f"""""") + res.append(f"""{e(name)}""") + if ring == 0: + assert isinstance(usage, apsw.ext.DatabasePageUsage) + total = storage(usage.pages_total * usage.page_size) + used = storage(usage.pages_used * usage.page_size) + res.append(f"""{used} / {total}""") + res.append(f"""{len(usage.tables):,} tables""") + res.append(f"""{len(usage.indices):,} indices""") + else: + if ring == 2: + kind = "table" if usage.tables else "index" + res.append(f"""({kind})""") + size = storage(usage.pages_used * usage.page_size) + res.append(f"""{size}""") + if ring == 1: + res.append(f"""{len(usage.tables):,} tables""") + res.append(f"""{len(usage.indices):,} indices""") + res.append( + f"""{usage.sequential_pages/max(usage.pages_used, 1):.0%} sequential""" + ) + res.append(f"""{storage(usage.data_stored)} SQL data""") + res.append(f"""{storage(usage.max_payload)} max payload""") + res.append(f"""{usage.cells:,} cells""") + + res.append("""""") + return "\n".join(res) + + # check we can get the information + root, group, each = (apsw.ext.analyze_pages(con, n, schema) for n in range(3)) + + # maps which element hovering over causes a response on + hover_response: dict[str, str] = {} + + # z-order is based on output order so texts go last + texts: list[str] = [] + + id_counter = 0 + + def next_ids(): + # return pais of element ids used to map slice to corresponding text + nonlocal id_counter + PREFIX = "id" + hover_response[f"{PREFIX}{id_counter}"] = f"{PREFIX}{id_counter + 1}" + id_counter += 2 + return f"{PREFIX}{id_counter-2}", f"{PREFIX}{id_counter-1}" + + print( + f"""""", + file=out, + ) + + # inner summary circle + id, resp = next_ids() + print(f"""""", file=out) + texts.append(text(pos_for_angle(0, 0), resp, os.path.basename(sys.argv[1]), 0, root)) + + # inner ring + start = Fraction() + for name, usage in group.items(): + ring1_proportion = Fraction(usage.pages_used, root.pages_total) + id, resp = next_ids() + print(slice(id, float(start), float(start + ring1_proportion), 1 / 3, 0.6), file=out) + texts.append(text(pos_for_angle(float(start + ring1_proportion / 2), (1 / 3 + 0.6) / 2), resp, name, 1, usage)) + ring2_start = start + start += ring1_proportion + + # corresponding outer ring + for child in sorted(usage.tables + usage.indices): + usage2 = each[child] + ring2_proportion = Fraction(usage2.pages_used, root.pages_total) + id, resp = next_ids() + print(slice(id, float(ring2_start), float(ring2_start + ring2_proportion), 2 / 3, 1.0), file=out) + texts.append( + text(pos_for_angle(float(ring2_start + ring2_proportion / 2), (2 / 3 + 1) / 2), resp, child, 2, usage2) + ) + ring2_start += ring2_proportion + + for t in texts: + print(t, file=out) + + print( + """", file=out) -resp = next_id() -texts.append(text(pos_for_angle(0, 0), resp, os.path.basename(sys.argv[1]), 0, root)) -hover_response[id] = resp +con = apsw.Connection(sys.argv[1]) -start = Fraction() -for name, usage in group.items(): - ring1_proportion = Fraction(usage.pages_used, root.pages_total) - id = next_id() - out.append(slice(id, float(start), float(start + ring1_proportion), 1 / 3, 0.6)) - resp = next_id() - texts.append(text(pos_for_angle(float(start + ring1_proportion / 2), (1 / 3 + 0.6) / 2), resp, name, 1, usage)) - hover_response[id] = resp - ring2_start = start - start += ring1_proportion - for child in sorted(usage.tables + usage.indices): - usage2 = each[child] - ring2_proportion = Fraction(usage2.pages_used, root.pages_total) - id = next_id() - out.append(slice(id, float(ring2_start), float(ring2_start + ring2_proportion), 2 / 3, 1.0)) - resp = next_id() - texts.append( - text(pos_for_angle(float(ring2_start + ring2_proportion / 2), (2 / 3 + 1) / 2), resp, child, 2, usage2) - ) - hover_response[id] = resp - ring2_start += ring2_proportion - -out.extend(texts) - -out.append("") - -out.append(footer) with open(sys.argv[2], "wt") as f: - f.write("\n".join(out)) + page_usage_to_svg(con, f)