// SPDX-FileCopyrightText: © 2026 Francesc Muñoz // SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! The `ziptop` command: `htop` for the network. const std = @import("std"); const Io = std.Io; const vaxis = @import("vaxis"); const datetime = @import("datetime"); const build_info = @import("build_info"); const config = @import("config.zig"); const sampler = @import("sampler.zig"); const theme = @import("theme.zig"); const tint = @import("tint.zig"); const ui = @import("ui.zig"); const Lookups = @import("lookups.zig").Lookups; /// While the program owns the terminal, log messages have nowhere to go: /// standard error is the screen, and libvaxis is chatty about the /// capabilities it detects, so they are dropped rather than written over it. /// Before and after, they go to standard error as usual -- which is where a /// leak the allocator finds at exit is reported. pub const std_options: std.Options = .{ .logFn = log, }; var screen_owned: std.atomic.Value(bool) = .init(false); fn log( comptime level: std.log.Level, comptime scope: @EnumLiteral(), comptime fmt: []const u8, args: anytype, ) void { if (screen_owned.load(.acquire)) return; std.log.defaultLog(level, scope, fmt, args); } /// If the program dies while it owns the terminal, the terminal is put back /// before the panic is reported, or the report vanishes with the alternate /// screen and leaves a shell with echo turned off. pub const panic = std.debug.FullPanic(struct { fn call(message: []const u8, first_trace_address: ?usize) noreturn { vaxis.recover(); std.debug.defaultPanic(message, first_trace_address); } }.call); const usage = \\ziptop {s} -- htop for your network \\ \\Usage: ziptop [options] \\ \\Options: \\ --map Fullscreen world-map mode \\ -v, --version Print version and exit \\ -h, --help Print this help and exit \\ \\Keys: j/k select · ⏎ inspect · esc back · s sort · m map · t theme · \\ [/] refresh rate · q quit \\ ; const Event = union(enum) { key_press: vaxis.Key, mouse: vaxis.Mouse, winsize: vaxis.Winsize, /// A sampler has put something new in the store. sample, }; const Loop = vaxis.Loop(Event); /// Wakes the event loop from a sampler's task. A full queue drops the wake, /// which costs nothing: the next sample sends another. fn wake(context: *anyopaque) void { const loop: *Loop = @ptrCast(@alignCast(context)); _ = loop.tryPostEvent(.sample) catch {}; } pub fn main(init: std.process.Init) !u8 { const io = init.io; const gpa = init.gpa; var mode: ui.Mode = .dashboard; var args = try init.minimal.args.iterateAllocator(gpa); defer args.deinit(); _ = args.skip(); while (args.next()) |arg| { if (std.mem.eql(u8, arg, "-h") or std.mem.eql(u8, arg, "--help")) { try writeOut(io, usage, .{build_info.version}); return 0; } else if (std.mem.eql(u8, arg, "-v") or std.mem.eql(u8, arg, "--version")) { try writeOut(io, "ziptop {s}\n", .{build_info.version}); return 0; } else if (std.mem.eql(u8, arg, "--map")) { mode = .map; } else { try writeErr(io, "ziptop: unknown option '{s}'; see --help\n", .{arg}); return 2; } } var arena_state: std.heap.ArenaAllocator = .init(gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); // The saved theme goes on before the first frame, so that the default // palette never flashes up first. const config_dir = try config.directory(arena, init.environ_map); const settings = config.load(arena, io, config_dir); if (settings.get("theme")) |name| if (theme.find(name)) |i| theme.apply(i); tint.shuffle(@truncate(@as(u96, @bitCast(Io.Clock.real.now(io).nanoseconds)))); var zone = loadZone(io, gpa, init.environ_map); defer if (zone) |*z| z.deinit(gpa); screen_owned.store(true, .release); defer screen_owned.store(false, .release); var tty_buffer: [4096]u8 = undefined; var tty: vaxis.Tty = try .init(io, &tty_buffer); defer tty.deinit(); const writer = tty.writer(); var vx = try vaxis.init(io, gpa, init.environ_map, .{}); defer vx.deinit(gpa, writer); var loop: Loop = .init(io, &tty, &vx); try loop.start(); defer loop.stop(); try loop.installResizeHandler(); defer loop.uninstallResizeHandler(); try vx.enterAltScreen(writer); try vx.queryTerminal(writer, .fromSeconds(1)); try vx.setMouseMode(writer, true); // The size arrives as an event only when it changes, so the first one is // asked for. try vx.resize(gpa, writer, try tty.getWinsize()); var store: sampler.Store = .{}; defer store.deinit(); // The map wants a calmer pace than the dashboard's half second. if (mode == .map) store.rate_index.store(1, .monotonic); const lookups = try gpa.create(Lookups); defer gpa.destroy(lookups); lookups.init(gpa, io); defer lookups.deinit(); var sampling: sampler.Sampler = .init(gpa, io, &store, lookups, .{ .context = &loop, .wake = wake }); defer sampling.deinit(); var tasks: Io.Group = .init; defer { lookups.close(); tasks.cancel(io); } try lookups.start(&tasks); try sampling.start(&tasks); var app: ui.App = .{ .gpa = gpa, .io = io, .store = &store, .mode = mode, .zone = zone, .config_dir = config_dir, }; defer app.deinit(); var frame: std.heap.ArenaAllocator = .init(gpa); defer frame.deinit(); try present(&app, &vx, writer, &store, io, &frame); while (!app.quit) { switch (try loop.nextEvent()) { .key_press => |key| app.handleKey(key), .mouse => |mouse| app.handleMouse(mouse), .winsize => |ws| try vx.resize(gpa, writer, ws), .sample => {}, } if (app.quit) break; try present(&app, &vx, writer, &store, io, &frame); } return 0; } fn present(app: *ui.App, vx: *vaxis.Vaxis, writer: *Io.Writer, store: *sampler.Store, io: Io, frame: *std.heap.ArenaAllocator) !void { defer _ = frame.reset(.retain_capacity); // The lock is held through the render, which is when the cells' text is // read, so no sample the frame points into can be freed under it. store.lock(io); defer store.unlock(io); app.draw(vx.window(), frame.allocator()); try vx.render(writer); try writer.flush(); } /// The local time zone for the clock in the title, as `TZ` says or as /// `/etc/localtime` is. Null, which the clock takes as UTC, when there is /// none to be had. fn loadZone(io: Io, gpa: std.mem.Allocator, environ: *std.process.Environ.Map) ?datetime.TimeZone { const system = datetime.tzdb.system; return switch (system.resolveTz(environ.get("TZ"))) { .local => system.loadLocal(io, gpa, null) catch null, .path => |path| system.loadLocal(io, gpa, path) catch null, .name => |name| blk: { if (environ.get("TZDIR")) |dir| { if (system.load(io, gpa, dir, name)) |z| break :blk z else |_| {} } for (system.search_directories) |dir| { if (system.load(io, gpa, dir, name)) |z| break :blk z else |_| {} } break :blk null; }, else => null, }; } fn writeOut(io: Io, comptime fmt: []const u8, args: anytype) !void { var buffer: [512]u8 = undefined; var file = Io.File.stdout().writer(io, &buffer); try file.interface.print(fmt, args); try file.interface.flush(); } fn writeErr(io: Io, comptime fmt: []const u8, args: anytype) !void { var buffer: [512]u8 = undefined; var file = Io.File.stderr().writer(io, &buffer); try file.interface.print(fmt, args); try file.interface.flush(); } test { // Zig runs a file's tests only when the file is imported for that // purpose; being reachable from `main` is not enough. _ = @import("addr.zig"); _ = @import("braille.zig"); _ = @import("collectors/connections.zig"); _ = @import("collectors/latency.zig"); _ = @import("collectors/system.zig"); _ = @import("collectors/throughput.zig"); _ = @import("collectors/traffic.zig"); _ = @import("config.zig"); _ = @import("exec.zig"); _ = @import("format.zig"); _ = @import("lookups.zig"); _ = @import("model.zig"); _ = @import("sampler.zig"); _ = @import("services.zig"); _ = @import("theme.zig"); _ = @import("tint.zig"); _ = @import("ui.zig"); _ = @import("worldmap_data.zig"); }