dev_hyperbuddy.erl - HTML REPL Interface & Utilities
Overview
Purpose: REPL-like HTML interface for AO-Core with metrics and debugging tools
Module: dev_hyperbuddy
Pattern: Static file serving with dynamic debugging and monitoring
Integration: HTTP endpoint for interactive node exploration and monitoring
This module provides a web-based interface for interacting with HyperBEAM nodes. It serves static HTML/CSS/JS files for dashboard, console, and graph visualization, along with dynamic endpoints for metrics, events, and message formatting.
Dependencies
- Erlang/OTP:
prometheus_http_impl,prometheus_cowboy,prometheus_registry - HyperBEAM:
hb_ao,hb_maps,hb_opts,hb_util,hb_format,hb_cache,hb_private,hb_event,hb_features - Device Layer:
dev_message
Public Functions Overview
%% Device Info
-spec info() -> DeviceInfo.
%% Metrics & Events
-spec metrics(Base, Req, Opts) -> {ok, MetricsResponse}.
-spec events(Base, Req, Opts) -> {ok, EventCounters}.
%% Formatting
-spec format(Base, Req, Opts) -> {ok, FormattedOutput}.
%% File Serving
-spec return_file(Name) -> {ok, FileResponse} | {error, not_found}.
-spec return_file(Device, Name) -> {ok, FileResponse} | {error, not_found}.
-spec return_file(Device, Name, Template) -> {ok, FileResponse} | {error, not_found}.
-spec return_error(Error, Opts) -> {ok, ErrorPage}.
%% Testing
-spec throw(Msg, Req, Opts) -> {error, binary()} | no_return().Public Functions
1. info/0
-spec info() -> DeviceInfo
when
DeviceInfo :: #{
default => fun(),
routes => map(),
excludes => [binary()]
}.Description: Return device information including routes and configuration.
Routes:index→index.html- Default message viewerdashboard→dashboard.html- HyperBEAM homepageconsole→console.html- Interactive REPLgraph→graph.html- Graph visualization- Static assets: CSS and JavaScript files
-module(dev_hyperbuddy_info_test).
-include_lib("eunit/include/eunit.hrl").
info_test() ->
Info = dev_hyperbuddy:info(),
?assert(is_map(Info)),
?assert(maps:is_key(default, Info)),
?assert(maps:is_key(routes, Info)),
?assert(maps:is_key(excludes, Info)).
info_routes_test() ->
Info = dev_hyperbuddy:info(),
Routes = maps:get(routes, Info),
?assertEqual(<<"index.html">>, maps:get(<<"index">>, Routes)),
?assertEqual(<<"dashboard.html">>, maps:get(<<"dashboard">>, Routes)),
?assertEqual(<<"console.html">>, maps:get(<<"console">>, Routes)).
info_excludes_test() ->
Info = dev_hyperbuddy:info(),
Excludes = maps:get(excludes, Info),
?assert(lists:member(<<"return_file">>, Excludes)).2. metrics/3
-spec metrics(Base, Req, Opts) -> {ok, MetricsResponse}
when
Base :: map(),
Req :: map(),
Opts :: map(),
MetricsResponse :: #{
<<"body">> => binary(),
binary() => binary()
}.Description: Return Prometheus-formatted metrics for the node.
Behavior:- If Prometheus enabled: Returns metrics in Prometheus format
- If Prometheus disabled: Returns message indicating metrics are disabled
Output Format: Prometheus text exposition format
Test Code:-module(dev_hyperbuddy_metrics_test).
-include_lib("eunit/include/eunit.hrl").
metrics_enabled_test() ->
% Verify module exports metrics/3
code:ensure_loaded(dev_hyperbuddy),
?assert(erlang:function_exported(dev_hyperbuddy, metrics, 3)).
metrics_disabled_test() ->
Opts = #{prometheus => false},
{ok, Response} = dev_hyperbuddy:metrics(#{}, #{}, Opts),
Body = maps:get(<<"body">>, Response),
?assertEqual(<<"Prometheus metrics disabled.">>, Body).3. events/3
-spec events(Base, Req, Opts) -> {ok, EventCounters}
when
Base :: map(),
Req :: map(),
Opts :: map(),
EventCounters :: map().Description: Return current event counters from the event system.
Returns: Map of event names to their occurrence counts.
Test Code:-module(dev_hyperbuddy_events_test).
-include_lib("eunit/include/eunit.hrl").
events_test() ->
% Verify module exports events/3
code:ensure_loaded(dev_hyperbuddy),
?assert(erlang:function_exported(dev_hyperbuddy, events, 3)).4. format/3
-spec format(Base, Req, Opts) -> {ok, FormattedOutput}
when
Base :: map(),
Req :: map(),
Opts :: map(),
FormattedOutput :: #{<<"body">> => binary()}.Description: Format messages using HyperBEAM's pretty printer for debugging.
Format Scopes:base- Format the base message (default)request- Format the request messagenode- Format the node optionsall- Format all three
format- Scope to format (base/request/node/all)format+list- List of scopestruncate-keys- Number of keys to show (default: infinity)
GET /.../~hyperbuddy@1.0/format=request
GET /.../~hyperbuddy@1.0/format+list=request,node
GET /.../~hyperbuddy@1.0/format=request?truncate-keys=20-module(dev_hyperbuddy_format_test).
-include_lib("eunit/include/eunit.hrl").
format_base_test() ->
Base = #{<<"data">> => <<"test">>},
Req = #{},
Opts = #{},
{ok, Response} = dev_hyperbuddy:format(Base, Req, Opts),
Body = maps:get(<<"body">>, Response),
?assert(is_binary(Body)),
?assert(byte_size(Body) > 0).
format_request_test() ->
Base = #{},
Req = #{<<"format">> => <<"request">>, <<"test">> => <<"value">>},
Opts = #{},
{ok, Response} = dev_hyperbuddy:format(Base, Req, Opts),
Body = maps:get(<<"body">>, Response),
?assert(is_binary(Body)).
format_all_test() ->
Base = #{<<"base-key">> => <<"value">>},
Req = #{<<"format">> => <<"all">>},
Opts = #{<<"node-key">> => <<"value">>},
{ok, Response} = dev_hyperbuddy:format(Base, Req, Opts),
Body = maps:get(<<"body">>, Response),
?assert(is_binary(Body)).
format_with_truncation_test() ->
Base = maps:from_list([{integer_to_binary(I), I} || I <- lists:seq(1, 50)]),
Req = #{<<"truncate-keys">> => 10},
{ok, Response} = dev_hyperbuddy:format(Base, Req, #{}),
?assert(maps:is_key(<<"body">>, Response)).
format_list_test() ->
Base = #{<<"test">> => <<"base">>},
Req = #{
<<"format">> => [<<"base">>, <<"request">>],
<<"test">> => <<"request">>
},
{ok, Response} = dev_hyperbuddy:format(Base, Req, #{}),
Body = maps:get(<<"body">>, Response),
?assert(is_binary(Body)).5. return_file/1, return_file/2, return_file/3
-spec return_file(Name) -> {ok, FileResponse} | {error, not_found}
when
Name :: binary(),
FileResponse :: #{
<<"body">> => binary(),
<<"content-type">> => binary()
}.
-spec return_file(Device, Name, Template) -> {ok, FileResponse} | {error, not_found}
when
Device :: binary(),
Name :: binary(),
Template :: map().Description: Serve static files from the priv/html directory with optional template substitution.
File Locations:priv/html/{device}/{filename}.html→text/html.js→text/javascript.css→text/css.png→image/png.ico→image/x-icon
Template Substitution:
Templates use {'{'}{'{'}key`}} syntax for variable replacement.
-module(dev_hyperbuddy_return_file_test).
-include_lib("eunit/include/eunit.hrl").
return_file_html_test() ->
{ok, Response} = dev_hyperbuddy:return_file(<<"hyperbuddy@1.0">>, <<"index.html">>),
?assert(maps:is_key(<<"body">>, Response)).
return_file_css_test() ->
{ok, Response} = dev_hyperbuddy:return_file(<<"hyperbuddy@1.0">>, <<"styles.css">>),
?assert(maps:is_key(<<"body">>, Response)).
return_file_js_test() ->
{ok, Response} = dev_hyperbuddy:return_file(<<"hyperbuddy@1.0">>, <<"utils.js">>),
?assert(maps:is_key(<<"body">>, Response)).
return_file_not_found_test() ->
Result = dev_hyperbuddy:return_file(<<"hyperbuddy@1.0">>, <<"nonexistent.html">>),
?assertEqual({error, not_found}, Result).
return_file_with_template_test() ->
% return_file/3 is not exported, verify return_file/2 works
code:ensure_loaded(dev_hyperbuddy),
?assert(erlang:function_exported(dev_hyperbuddy, return_file, 2)).6. return_error/2
-spec return_error(Error, Opts) -> {ok, ErrorPage}
when
Error :: binary() | map(),
Opts :: map(),
ErrorPage :: #{
<<"body">> => binary(),
<<"content-type">> => binary()
}.Description: Generate and return an error page with formatted error message.
Error Input:- Binary: Used directly as error message
- Map: Formatted using
hb_format:format/3
-module(dev_hyperbuddy_return_error_test).
-include_lib("eunit/include/eunit.hrl").
return_error_binary_test() ->
Error = <<"Something went wrong">>,
{ok, Response} = dev_hyperbuddy:return_error(Error, #{}),
?assert(maps:is_key(<<"body">>, Response)).
return_error_map_test() ->
Error = #{<<"message">> => <<"Error occurred">>},
{ok, Response} = dev_hyperbuddy:return_error(Error, #{}),
Body = maps:get(<<"body">>, Response),
?assert(is_binary(Body)).7. throw/3
-spec throw(Msg, Req, Opts) -> {error, binary()} | no_return()
when
Msg :: map(),
Req :: map(),
Opts :: map().Description: Test endpoint for validating 500 HTTP error response handling.
Behavior:- In
prodmode: Returns error message - In
debugmode: Throws intentional error
-module(dev_hyperbuddy_throw_test).
-include_lib("eunit/include/eunit.hrl").
throw_prod_mode_test() ->
Opts = #{mode => prod},
{error, Msg} = dev_hyperbuddy:throw(#{}, #{}, Opts),
?assertEqual(<<"Forced-throw unavailable in `prod` mode.">>, Msg).
throw_debug_mode_test() ->
Opts = #{mode => debug},
?assertThrow(
{intentional_error, _},
dev_hyperbuddy:throw(#{}, #{}, Opts)
).Common Patterns
%% Access dashboard
% Navigate to: http://node:port/~hyperbuddy@1.0/dashboard
%% Access console REPL
% Navigate to: http://node:port/~hyperbuddy@1.0/console
%% Get Prometheus metrics
{ok, Metrics} = hb_http:get(
Node,
<<"/~hyperbuddy@1.0/metrics">>,
#{}
),
Body = maps:get(<<"body">>, Metrics).
%% Get event counters
{ok, Events} = hb_http:get(
Node,
<<"/~hyperbuddy@1.0/events">>,
#{}
).
%% Format a message for debugging
{ok, Formatted} = hb_http:get(
Node,
<<"/~hyperbuddy@1.0/format=request">>,
#{}
),
?event({formatted_output, maps:get(<<"body">>, Formatted)}).
%% Format with truncation
{ok, Truncated} = hb_http:get(
Node,
<<"/~hyperbuddy@1.0/format=base?truncate-keys=20">>,
#{}
).
%% Format all components
{ok, All} = hb_http:get(
Node,
<<"/~hyperbuddy@1.0/format=all">>,
#{}
).
%% Serve custom error page
ErrorMsg = #{<<"message">> => <<"Custom error">>},
{ok, ErrorPage} = dev_hyperbuddy:return_error(ErrorMsg, Opts),
% Return ErrorPage as HTTP response
%% Serve file with template
Template = #{
<<"title">> => <<"My Page">>,
<<"content">> => <<"Dynamic content">>
},
{ok, Page} = dev_hyperbuddy:return_file(
<<"my-device@1.0">>,
<<"template.html">>,
Template
).Routing System
Route Configuration
#{
routes => #{
<<"index">> => <<"index.html">>,
<<"dashboard">> => <<"dashboard.html">>,
<<"console">> => <<"console.html">>,
<<"styles.css">> => <<"styles.css">>,
<<"utils.js">> => <<"utils.js">>
}
}Route Resolution
Request: /~hyperbuddy@1.0/dashboard
↓
Lookup: routes[<<"dashboard">>]
↓
Filename: dashboard.html
↓
Path: priv/html/hyperbuddy@1.0/dashboard.html
↓
Serve: File contents with content-typeTemplate System
Template Syntax
The template system uses double curly braces for variable substitution:
<html>
<head>
<title>{"{{"}title{"}}"}</title>
</head>
<body>
<h1>{"{{"}heading{"}}"}</h1>
<p>{"{{"}content{"}}"}</p>
</body>
</html>Template Application
Template = #{
<<"title">> => <<"My Page">>,
<<"heading">> => <<"Welcome">>,
<<"content">> => <<"Hello, World!">>
},
{ok, Result} = return_file(Device, <<"template.html">>, Template).Replacement Process
1. Load file from disk
2. For each key-value in template:
- Find the placeholder pattern in file
- Replace with value
3. Return processed contentFormatting Options
Scope Options
base (default)
#{<<"format">> => <<"base">>}
% Formats the base message only#{<<"format">> => <<"request">>}
% Formats the request message#{<<"format">> => <<"node">>}
% Formats the node options#{<<"format">> => <<"all">>}
% Formats base, request, and node#{<<"format">> => [<<"base">>, <<"request">>]}
% Formats multiple specified scopesTruncation
No Truncation (default)#{<<"truncate-keys">> => infinity}
% Shows all keys#{<<"truncate-keys">> => 20}
% Shows first 20 keys onlyStatic Assets
HTML Files
index.html- Message viewerdashboard.html- Node dashboardconsole.html- Interactive REPLgraph.html- Graph visualization404.html- Not found page500.html- Error page
JavaScript Files
metrics.js- Metrics displaydevices.js- Device managementutils.js- Utility functionsdashboard.js- Dashboard logicgraph.js- Graph rendering
CSS Files
styles.css- Global styles
Prometheus Integration
Metrics Endpoint
GET /~hyperbuddy@1.0/metrics# HELP metric_name Description
# TYPE metric_name counter
metric_name{label="value"} 42Configuration
Opts = #{
prometheus => true % Enable metrics
},
hb_http_server:start_node(Opts).Metrics Access
curl http://localhost:port/~hyperbuddy@1.0/metricsEvent Counters
Counter Structure
#{
<<"event_name">> => Count,
<<"another_event">> => Count2
}Access Pattern
{ok, Counters} = hb_http:get(
Node,
<<"/~hyperbuddy@1.0/events">>,
#{}
),
RequestCount = maps:get(<<"request_received">>, Counters, 0).References
- Message Device -
dev_message.erl - Formatting -
hb_format.erl - Events -
hb_event.erl - HTTP Server -
hb_http_server.erl - Cache -
hb_cache.erl - Prometheus -
prometheuslibrary
Notes
- HTML Interface: Provides web-based node interaction
- Static Serving: Serves HTML, CSS, JS files from priv directory
- Template Support: Simple placeholder template replacement
- Metrics Export: Prometheus-compatible metrics endpoint
- Event Monitoring: Real-time event counter access
- Debug Formatting: Pretty-print messages for debugging
- Scope Control: Format base, request, or node separately
- Truncation: Limit displayed keys for large messages
- Content Types: Automatic content-type detection
- Error Pages: Formatted error page generation
- Security: return_file excluded from API
- Test Mode: Intentional error throwing for testing
- Route Protection: Only serves explicitly listed files
- Private Reset: Removes private data before formatting
- Cache Loading: Ensures all references loaded before display