diff --git a/.github/workflows/timbersh.yml b/.github/workflows/timbersh.yml index 43eca0b..162c93c 100644 --- a/.github/workflows/timbersh.yml +++ b/.github/workflows/timbersh.yml @@ -77,11 +77,13 @@ jobs: # works; `--no-install-recommends` is the workstation that only # ever talks to other machines. # - # The version belongs there too, and only as a hint: the viewer is - # nothing but seeks and the bounded `from_chunk` one landed in - # 0.26.0. What a dpkg constraint cannot reach is the case that - # matters anyway — a REMOTE target, about which it says nothing — - # so the runtime diagnosis is the real guard. + # The version belongs there too, and only as a hint: `graph ... + # extracting` runs `timberfs tally --try` HERE and resolves an + # extractor name against the directory the timberfs package ships, + # both of which landed in 0.32.0. What a dpkg constraint cannot + # reach is the case that matters anyway — a REMOTE target, about + # which it says nothing — so the runtime diagnosis is the real + # guard. - name: Build the .deb run: | V=$(tr -d '[:space:]' < tools/VERSION) @@ -95,7 +97,7 @@ jobs: Priority: optional Architecture: all Depends: python3, zstd - Recommends: timberfs (>= 0.26.0) + Recommends: timberfs (>= 0.32.0) Suggests: gnuplot Maintainer: Torstein Tauno Svendsen Homepage: https://github.com/torstei/timberfs diff --git a/packaging/timbergraph.1 b/packaging/timbergraph.1 index c9a2e4a..586440e 100644 --- a/packaging/timbergraph.1 +++ b/packaging/timbergraph.1 @@ -150,6 +150,11 @@ Draw somewhere other than the terminal. .B \-\-window needs a display and gnuplot's qt or x11 terminal. .TP +.BI \-\-width " N" ", " \-\-height " N" +The plot's size \(em CHARACTERS on the terminal, where it defaults to the +terminal's own, and PIXELS everywhere else (1000x500, or 900x500 for +.BR \-\-window ). +.TP .BI \-\-tsv " FILE" ", " \-\-gnuplot " FILE" The table behind the picture, and the script that drew it with its data inline so it re-runs alone diff --git a/packaging/timbersh.1 b/packaging/timbersh.1 index 6d86bd6..d93b983 100644 --- a/packaging/timbersh.1 +++ b/packaging/timbersh.1 @@ -429,7 +429,9 @@ read and a plot of what came back, with no response kind of its own. ⚠ The HOST is a label like any other: a fleet answer that merged two of them would be two series a reader takes for one, so .B "by host" -keeps them apart and leaving it out sums them on purpose. .B "graph M from SRC extracting NAME" +keeps them apart and leaving it out sums them on purpose. +.PP +.B "graph M from SRC extracting NAME" makes the numbers HERE, from a store that has NO TALLY at all \(em for trying an extractor against real data before provisioning anything. ⚠ It moves the RAW LOG, tens of megabytes an hour per host against kilobytes diff --git a/tests/timbergraph/test-timbergraph b/tests/timbergraph/test-timbergraph index 78d2632..d4d2336 100755 --- a/tests/timbergraph/test-timbergraph +++ b/tests/timbergraph/test-timbergraph @@ -222,6 +222,8 @@ def test_an_extractor_is_a_path_or_a_name(): check("by path", doc, g.resolve_extractor(doc)) check("the unit", "x", g.extractor_facts(["mine"])["m"]["unit"]) raises("neither", lambda: g.resolve_extractor("absent"), "no extractor") + raises("names where it looked", lambda: g.resolve_extractor("absent"), + d) finally: if old is None: del os.environ["XDG_CONFIG_HOME"] @@ -231,6 +233,23 @@ def test_an_extractor_is_a_path_or_a_name(): sh.rmtree(home, ignore_errors=True) +def test_a_name_that_resolves_nowhere_says_where_it_looked(): + """A directory is listed only if it exists, so where none does the + list is empty — and the failure then named nowhere at all, which says + nothing about where the file should go. The directories are replaced + rather than the run being skipped where a real one exists: a test + that runs on some machines and not others has told you nothing.""" + was = g.EXTRACTOR_DIRS + g.EXTRACTOR_DIRS = ("/nonexistent/packaged", "/nonexistent/site", + "~/nonexistent/mine") + try: + check("none exist", [], g.extractor_dirs()) + raises("names the packaged one", lambda: g.resolve_extractor("absent"), + "/nonexistent/packaged") + finally: + g.EXTRACTOR_DIRS = was + + def test_the_time_axis_is_local_and_snaps_to_the_clock(): """gnuplot renders a time axis as though every value were UTC, so the shift is in the number — and a tic lands on the hour a reader diff --git a/tests/timbersh/test-timbersh b/tests/timbersh/test-timbersh index f4e2b68..335d912 100755 --- a/tests/timbersh/test-timbersh +++ b/tests/timbersh/test-timbersh @@ -457,6 +457,21 @@ def a_graph_makes_the_fleet_a_dimension_rather_than_merging_it(): s.cleanup() +@test +def a_graph_refuses_a_host_label_it_would_have_to_write_over(): + """The shell adds `host` itself, so a metric that already carries one + would be two meanings in one label. The refusal has to survive being + RAISED — it names the metric, and naming the wrong variable turns the + diagnosis into a NameError from inside the shell.""" + labelled = "2026-09-06T13:37:00.000Z 60s heap host=one last=100\n" + s = Session({h: {"stores": [store("t", ID_A)], "loglines": labelled} + for h in ("a", "b")}, hosts=["a", "b"]).run( + "graph heap from [class=tally];") + assert "already carry" in s.out, s.out + assert "graph heap from" in s.out, s.out + s.cleanup() + + @test def a_graph_refuses_a_cumulative_ladder_rather_than_multiplying_it(): """`le` series are cumulative, so stacking them multiplies the count diff --git a/tools/README.md b/tools/README.md index 1174380..0e11622 100644 --- a/tools/README.md +++ b/tools/README.md @@ -597,6 +597,10 @@ Several metrics go on one graph, comma-separated — which is how two stores answer at once, since `[class=tally]` covers both and the metric name is what tells them apart. +`using ` names the extractor document the numbers came from, which is +where the **unit** is written down; it is `timbergraph --using` below, with the +same lookup. `svg 'FILE'` draws where `png 'FILE'` does. + ### `extracting` — metrics from a store that has no tally ``` diff --git a/tools/timbergraph.py b/tools/timbergraph.py index 72f3ee9..f087f21 100644 --- a/tools/timbergraph.py +++ b/tools/timbergraph.py @@ -520,10 +520,18 @@ def resolve_extractor(name_or_path): candidate = os.path.join(d, f"{name_or_path}.json") if os.path.isfile(candidate): return candidate - where = ", ".join(extractor_dirs()) or "no extractor directory that exists" + # ⚠ A directory is listed only if it EXISTS, so where none does the + # list is empty — and a failure naming nowhere says nothing about + # where the file should go. + found = extractor_dirs() + if not found: + raise Bad( + f"no extractor {name_or_path!r} — it is not a path that exists, and " + f"there is no extractor directory to search: none of " + f"{', '.join(EXTRACTOR_DIRS)} exists") raise Bad( f"no extractor {name_or_path!r} — neither a path that exists nor a " - f"document in {where}" + f"document in {', '.join(found)}" ) @@ -812,7 +820,8 @@ def main(argv=None): ap.add_argument("--svg", metavar="FILE") ap.add_argument("--window", action="store_true", help="an interactive window; needs a display and gnuplot-qt") - ap.add_argument("--width", type=int) + ap.add_argument("--width", type=int, + help="characters on the terminal, pixels in an image") ap.add_argument("--height", type=int) ap.add_argument("--tsv", metavar="FILE", help="the table behind the picture") diff --git a/tools/timbersh b/tools/timbersh index 71e118c..60b5892 100755 --- a/tools/timbersh +++ b/tools/timbersh @@ -197,7 +197,7 @@ HELP = """ select from [where ...] [limit N [chunks]] [into view]; tail from [where ...]; graph [, ...] from [by LABEL] [where ...] - [quantile Q] [rate] [png 'FILE']; + [quantile Q] [rate] [using ] [png 'FILE'|svg 'FILE']; graph against from [...]; graph from extracting where ... ; @@ -1676,7 +1676,8 @@ class Shell: if at is None or at < 2 or at + 1 >= len(toks): raise ValueError( "graph [, ...] from [by