Files
punktfunk/web/src/stories/Logs.stories.tsx
T
enricobuehlerandClaude Opus 5 365caa23be fix(plugins): plugin output reaches the console's log page, and /tmp is no longer hidden from the runner
A user could not get the VirtualHere plugin to use their VirtualHere client
and asked, reasonably, where the logs were. There was no good answer, and the
reason they were stuck turned out to be ours.

**The runner could not see /tmp.** `punktfunk-scripting.service` set
PrivateTmp=yes, which hands the unit a private tmpfs. But integrating with
things already running on the box is the entire job of a plugin, and on Linux
those talk over /tmp: VirtualHere's client IPC is the FIFO pair /tmp/vhclient +
/tmp/vhclient_response, X11 is /tmp/.X11-unix. So the plugin launched the vendor
binary happily and could then never reach the daemon behind it — while the same
command worked perfectly in the operator's own shell, because that shell has the
real /tmp. No config change could fix it, which is exactly the loop the report
described. PrivateTmp is now off, with /tmp added to ReadWritePaths (which
ProtectSystem=strict would otherwise make read-only).

**Plugin logs now land in the console.** Plugins are not host child processes —
the runner is a separate bun process that import()s each plugin in-process — so
nothing they print passed through the host's tracing, and the console's Logs
page could not show a single plugin line. The fallback was journalctl on Linux;
on Windows the runner's scheduled task writes no log file at all, so a failing
plugin was diagnosable only by stopping the task and re-running the runner by
hand. Both mean shell access on the host box, which is what the console exists
to avoid — and it left the one question a stuck user asks with no answer.

So the runner now tees its output to POST /api/v1/plugins/logs, and those lines
join the host's own ring under one cursor, targeted plugin:<name>. The console
grows a Host/Plugins switch beside the level filter; an empty Plugins view says
the thing that is actually usually wrong (the runner isn't running) rather than
"adjust the filter".

The shipper keeps stdout authoritative — journald and foreground output are
unchanged whatever the host is doing — and is built so that logging can never
hurt the thing being logged: it never throws into a caller, holds a bounded
queue that drops oldest and then says how many, backs off when the host is away
(a restart is normal), and re-sends a batch the host failed to take. Lines
logged while a POST is in flight are kept, which cost one round to get right:
the first version held its recursion guard across the await and silently dropped
exactly the lines a busy plugin produces.

Runner lines that report a failure (a refused unit file, a crashed plugin, a
give-up) now go out at warn/error instead of all arriving as INFO, so the
console's level filter means something for them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:47:51 +02:00

130 lines
3.1 KiB
TypeScript

import type { Meta, StoryObj } from "@storybook/react-vite";
import type { LogEntry } from "@/api/gen/model/logEntry";
import { LogsCard } from "@/sections/Logs/LogsCard";
import { LogsView } from "@/sections/Logs/view";
const noop = () => {};
// A deterministic slice of host logs covering every level, incl. the gamepad-driver health lines
// the page exists to surface — no live host needed.
const BASE = 1_750_000_000_000;
const entry = (
seq: number,
level: string,
target: string,
msg: string,
): LogEntry => ({ seq, ts_ms: BASE + seq * 750, level, target, msg });
const fixtureEntries: LogEntry[] = [
entry(
1,
"INFO",
"punktfunk_host",
"punktfunk-host 0.4.2 (punktfunk_core ABI v2)",
),
entry(
2,
"INFO",
"punktfunk_host::mgmt",
"management API listening over HTTPS addr=0.0.0.0:47990",
),
entry(
3,
"DEBUG",
"punktfunk_host::discovery",
"mDNS advertise _punktfunk._udp pair=required",
),
entry(
4,
"INFO",
"punktfunk_host::punktfunk1",
"session start mode=1920x1080@60 codec=hevc",
),
entry(
5,
"INFO",
"punktfunk_host::inject",
"virtual Xbox 360 created (Windows XUSB companion)",
),
entry(
6,
"WARN",
"punktfunk_host::inject",
"gamepad driver not attached to Global\\pfxusb-shm-0 after 3s — is the pf_xusb driver installed? (punktfunk-host.exe driver install --gamepad)",
),
entry(
7,
"ERROR",
"punktfunk_host::inject",
"virtual Xbox 360 creation failed — controller input disabled (is the pf_xusb driver installed?)",
),
entry(
8,
"INFO",
"punktfunk_host::encode",
"NVENC opened 1920x1080 nv12 gop=inf rfi=on",
),
// Lines the plugin runner shipped up (`POST /api/v1/plugins/logs`), targeted `plugin:<name>`.
// They share the ring and the cursor with the host's own, which is what the Host/Plugins
// filter exists to separate — so the fixture has to carry both to be worth screenshotting.
entry(9, "INFO", "plugin:runner", "starting virtualhere"),
entry(
10,
"INFO",
"plugin:virtualhere",
"bound couch-deck.11 (Thrustmaster T300RS) for stream",
),
entry(
11,
"ERROR",
"plugin:virtualhere",
"vhclientx86_64 failed (ETIMEDOUT) — the VirtualHere client is not answering on /tmp/vhclient",
),
];
const meta = {
title: "Pages/Logs",
component: LogsView,
parameters: { layout: "padded" },
} satisfies Meta<typeof LogsView>;
export default meta;
type Story = StoryObj<typeof meta>;
// The real page layout (LogsView) with the pure viewer card + fixture entries in its slot.
// `shareMode` is probed from the browser on the live page; the stories pin one of each so both the
// desktop (clipboard) and mobile (share sheet) affordance stay covered by the screenshot run.
export const Following: Story = {
args: {
viewer: (
<LogsCard
entries={fixtureEntries}
follow
onFollow={noop}
onClear={noop}
onDownload={noop}
onShare={noop}
shareMode="copy"
dropped={false}
/>
),
},
};
export const PausedWithGap: Story = {
args: {
viewer: (
<LogsCard
entries={fixtureEntries}
follow={false}
onFollow={noop}
onClear={noop}
onDownload={noop}
onShare={noop}
shareMode="share"
dropped
/>
),
},
};