Something went wrong. Try again.
htop for your network: a real-time IP traffic monitor for the terminal, ported to Zig from iptop
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260// SPDX-FileCopyrightText: © 2026 Francesc Muñoz// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// 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");}