Developer-Dashboard

 view release on metacpan or  search on metacpan

Changes  view on Meta::CPAN

      link, and live date-time line under that mode while leaving
      `/system/status`, bookmark rendering, and `dashboard ps1` unchanged
    - added focused web, config, CLI smoke, and browser coverage proving the
      no-indicators mode persists across restart and only affects the web
      interface chrome

2.52  2026-04-18
    - added `dashboard serve --no-editor` and the compatibility alias
      `dashboard serve --no-endit` so the web UI can run in a persisted
      read-only mode without exposing the bookmark editor chrome
    - blocked bookmark editor GET and POST routes plus saved bookmark source
      routes under that mode, while keeping normal saved-page render routes
      available and hiding the Share, Play, and View Source links
    - added focused web, config, and CLI smoke coverage proving read-only
      serve persistence across restart and direct POST-bypass denial, and
      synced the manuals for the new serve mode

2.51  2026-04-18
    - added `dashboard docker list` with `--enabled` and `--disabled`
      filters so users can inspect isolated compose service marker state
      without scanning layered docker folders by hand

README.md  view on Meta::CPAN

printf 'alpha.beta=5\n' | dashboard propq alpha.beta
dashboard jq file.json '$d'
```

Start the local app:

```bash
dashboard serve
```

Open the root path with no bookmark path to get the free-form bookmark editor directly. If you start the web service with `dashboard serve --no-editor` or `dashboard serve --no-endit`, the browser stays read-only instead and direct editor/source rout...

Stop the local app and collector loops:

```bash
dashboard stop
```

Interactive terminal runs now print a task board on `stderr` first, then mark each stop step as it finishes so the command does not appear hung while the runtime waits for managed shutdown.

Restart the local app and configured collector loops:

integration/blank-env/run-integration.pl  view on Meta::CPAN

        'dashboard docker compose --dry-run',
        'dashboard docker compose --project ' . _shell_quote($compose) . ' --dry-run config'
    );
    _assert_match( $docker_dry->{stdout}, qr/"command"\s*:/, 'dashboard docker compose dry-run returns resolved command' );
    _assert_match( $docker_dry->{stdout}, qr/compose\.yaml/, 'dashboard docker compose dry-run includes compose file' );

    my $serve = _run_shell( 'dashboard serve', $project_cd . 'dashboard serve' );
    _assert_match( $serve->{stdout}, qr/"pid"\s*:/, 'dashboard serve starts background web service' );
    _wait_for_http( 'http://127.0.0.1:7890/', 200 );

    my $blocked_transient = _run_shell(
        'curl transient token denied by default',
        'curl -sS -o /tmp/transient-denied.body -w \'%{http_code}\' ' . _shell_quote( 'http://127.0.0.1:7890/?token=' . $token ),
    );
    _assert_match( $blocked_transient->{stdout}, qr/^403$/, 'loopback transient token route is denied by default' );
    _assert_match( _read_text('/tmp/transient-denied.body'), qr/Transient token URLs are disabled/, 'loopback transient token denial explains the policy' );

    my $root = _run_shell( 'curl loopback root', q{curl -fsS http://127.0.0.1:7890/} );
    _assert_match( $root->{stdout}, qr/instruction-editor/, 'loopback root serves the bookmark editor' );
    my $root_dom = _run_browser_dom( 'browser loopback root', 'http://127.0.0.1:7890/', user_data_dir => $profile );
    _assert_match( $root_dom, qr/instruction-editor/, 'browser loopback root renders the editor DOM' );
    _assert_match( $root_dom, qr/TITLE:\s+Developer Dashboard/, 'browser loopback root shows bookmark source text' );

    my $project_dom = _run_browser_dom( 'browser fake project page', 'http://127.0.0.1:7890/app/project-home', user_data_dir => $profile );
    _assert_match( $project_dom, qr/project-marker/, 'browser renders fake project bookmark page' );

lib/Developer/Dashboard.pm  view on Meta::CPAN

  printf '{"alpha":{"beta":2}}' | dashboard jq alpha.beta
  printf 'alpha:\n  beta: 3\n' | dashboard yq alpha.beta
  printf '[alpha]\nbeta = 4\n' | dashboard tomq alpha.beta
  printf 'alpha.beta=5\n' | dashboard propq alpha.beta
  dashboard jq file.json '$d'

Start the local app:

  dashboard serve

Open the root path with no bookmark path to get the free-form bookmark editor directly. If you start the web service with C<dashboard serve --no-editor> or C<dashboard serve --no-endit>, the browser stays read-only instead and direct editor/source ro...

Stop the local app and collector loops:

  dashboard stop

Interactive terminal runs now print a task board on C<stderr> first, then
mark each stop step as it finishes so the command does not appear hung while
the runtime waits for managed shutdown.

Restart the local app and configured collector loops:

share/private-cli/serve  view on Meta::CPAN


  dashboard serve --foreground

Run the public built-in command path that stages or re-enters this helper.

Example 2:

  dashboard serve --no-editor

Start the dashboard in read-only browser mode so bookmark edit and source
routes stay blocked.

Example 3:

  dashboard serve --no-indicators

Start the dashboard with the top-right browser context and indicator strip
removed while leaving the left-side page chrome and terminal prompt behavior
unchanged.

Example 4:

t/03-web-app.t  view on Meta::CPAN

like(decode('UTF-8', $status_icon_body), qr/"alias"\s*:\s*"🔑"/, 'legacy status endpoint exposes configured collector indicator icons instead of collector names');
like($app->_prompt_summary, qr/🔑/, 'page top-right prompt summary prefers the configured collector indicator icon');
$config->save_global_web_settings( no_editor => 1 );
my ($readonly_render_code, undef, $readonly_render_body) = @{ $app->handle(path => '/app/welcome', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' }) };
is($readonly_render_code, 200, 'no-editor mode still renders saved pages');
unlike($readonly_render_body, qr/id="share-url"/, 'no-editor mode hides the share link from render views');
unlike($readonly_render_body, qr/id="view-source-url"/, 'no-editor mode hides the view-source link from render views');
unlike($readonly_render_body, qr/id="play-url"/, 'no-editor mode hides the play link from render views');
my ($readonly_source_code, $readonly_source_type, $readonly_source_body) = @{ $app->handle(path => '/app/welcome/source', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' }) };
is($readonly_source_code, 403, 'no-editor mode blocks saved page source routes');
like($readonly_source_type, qr/text\/plain/, 'no-editor blocked source route returns plain text');
like($readonly_source_body, qr/read-only|no-editor/i, 'no-editor blocked source route explains the read-only restriction');
my ($readonly_edit_code, $readonly_edit_type, $readonly_edit_body) = @{ $app->handle(path => '/app/welcome/edit', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' }) };
is($readonly_edit_code, 403, 'no-editor mode blocks saved page editor routes');
like($readonly_edit_type, qr/text\/plain/, 'no-editor blocked editor route returns plain text');
like($readonly_edit_body, qr/read-only|no-editor/i, 'no-editor blocked editor route explains the read-only restriction');
my $readonly_post_instruction = uri_escape("TITLE: Changed\n:--------------------------------------------------------------------------------:\nBOOKMARK: welcome\n:--------------------------------------------------------------------------------:\nHTM...
my ($readonly_post_code, $readonly_post_type, $readonly_post_body) = @{ $app->handle(
    path        => '/app/welcome/edit',
    method      => 'POST',
    body        => 'instruction=' . $readonly_post_instruction,
    remote_addr => '127.0.0.1',
    headers     => { host => '127.0.0.1' },
) };
is($readonly_post_code, 403, 'no-editor mode blocks saved page editor POST saves');
like($readonly_post_type, qr/text\/plain/, 'no-editor blocked editor POST returns plain text');
like($readonly_post_body, qr/read-only|no-editor/i, 'no-editor blocked editor POST explains the read-only restriction');
my $welcome_after_block = $store->load_saved_page('welcome');
is($welcome_after_block->as_hash->{layout}{body}, 'hello from app [% stash.name %]', 'no-editor mode leaves the saved bookmark unchanged after a blocked POST');
my ($readonly_root_code, $readonly_root_type, $readonly_root_body, $readonly_root_headers) = @{ $app->handle(path => '/', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' }) };
is($readonly_root_code, 302, 'no-editor mode still lets the root route redirect to a saved index page');
like($readonly_root_type, qr/text\/plain/, 'no-editor root redirect still returns the standard plain-text redirect body');
is($readonly_root_headers->{Location}, '/app/index', 'no-editor root redirect still targets the saved index page');
$config->save_global_web_settings( no_editor => 0 );
$config->save_global_web_settings( no_indicators => 1 );
my ($noind_render_code, undef, $noind_render_body) = @{ $app->handle(path => '/app/welcome', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' }) };
is($noind_render_code, 200, 'no-indicators mode still renders saved pages');
unlike($noind_render_body, qr/id="status-on-top"/, 'no-indicators mode hides the top-right indicator strip');
unlike($noind_render_body, qr/id="status-datetime"/, 'no-indicators mode hides the top-right date-time marker');

t/03-web-app.t  view on Meta::CPAN

    open my $fh, '>', $store->page_file('legacy-forward-override') or die $!;
    print {$fh} '/ajax/demo.json?type=text&status=default';
    close $fh;
}
my ($code9, undef, $body9) = @{ $app->handle(path => '/app/legacy-forward-override', query => 'status=override', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' }) };
is($code9, 200, 'legacy /app saved-url forwarding with override works');
like(drain_stream_body($body9), qr/"ok"\s*:\s*1/, 'forwarded saved-url override still reaches ajax payload through the stream response');

{
    local $ENV{DEVELOPER_DASHBOARD_ALLOW_TRANSIENT_URLS} = 0;
    my $manual_ajax_token = uri_escape( encode_payload(q{print j { blocked => 1 };}) );
    my ($blocked_ajax_code, $blocked_ajax_type, $blocked_ajax_body) = @{ $app->handle(path => '/ajax', query => "token=$manual_ajax_token&type=json", remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' }) };
    is($blocked_ajax_code, 403, 'legacy ajax token route is denied when transient token URLs are disabled');
    like($blocked_ajax_type, qr/text\/plain/, 'legacy ajax token denial returns plain text');
    like($blocked_ajax_body, qr/Transient token URLs are disabled/, 'legacy ajax token denial explains the policy');
}
{
    local $ENV{DEVELOPER_DASHBOARD_ALLOW_TRANSIENT_URLS} = 1;
    my $manual_ajax_token = uri_escape( encode_payload(q{die "token ajax died\n";}) );
    my ($manual_ajax_error_code, $manual_ajax_error_type, $manual_ajax_error_body) = @{ $app->handle(path => '/ajax', query => "token=$manual_ajax_token&type=text", remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' }) };
    is($manual_ajax_error_code, 200, 'legacy ajax token runtime errors still return the streaming response shape');
    like($manual_ajax_error_type, qr/text\/plain/, 'legacy ajax token runtime errors keep the requested content type');
    like(drain_stream_body($manual_ajax_error_body), qr/token ajax died/, 'legacy ajax token runtime errors stream the runtime error text');
}

t/07-core-units.t  view on Meta::CPAN

    ok( !-e $cleanup_result_path, '_cleanup_temp_files removes old runtime result temp files' );
    ok(
        grep( { $_->{kind} eq 'ajax-temp-file' && $_->{path} eq $cleanup_ajax_path } @removed ),
        '_cleanup_temp_files reports removed ajax temp files',
    );
    ok(
        grep( { $_->{kind} eq 'result-temp-file' && $_->{path} eq $cleanup_result_path } @removed ),
        '_cleanup_temp_files reports removed runtime result temp files',
    );

    my $blocked_tmp = tempdir( CLEANUP => 1 );
    my ( $ajax_fh, $ajax_path ) = tempfile(
        'developer-dashboard-ajax-FAIL-XXXXXX',
        DIR    => $blocked_tmp,
        UNLINK => 0,
    );
    print {$ajax_fh} "still here";
    close $ajax_fh or die "Unable to close $ajax_path: $!";
    utime time - 7200, time - 7200, $ajax_path or die "Unable to age $ajax_path: $!";
    if ( $> == 0 ) {
        pass('_cleanup_ajax_temp_files unlink-failure branch is skipped under root because root can still remove the temp file despite directory permission tightening');
    }
    else {
        chmod 0555, $blocked_tmp or die "Unable to chmod $blocked_tmp: $!";
        {
            no warnings qw(redefine once);
            local *File::Spec::tmpdir = sub { return $blocked_tmp };
            dies_like(
                sub {
                    $ajax_keeper->_cleanup_temp_files(
                        min_age_seconds => 60,
                        scanned         => { state_roots => 0, ajax_temp_files => 0, result_temp_files => 0 },
                    );
                },
                qr/Unable to remove stale Ajax temp file/,
                '_cleanup_temp_files dies when unlink fails and the temp file still exists',
            );
        }
        chmod 0755, $blocked_tmp or die "Unable to restore $blocked_tmp permissions: $!";
    }
    unlink $ajax_path or die "Unable to remove $ajax_path after ajax unlink failure coverage: $!";
}

dies_like( sub { Developer::Dashboard::UpdateManager->new }, qr/Missing config/, 'update manager requires config' );

{
    package Local::EnvLoader::Functions;

    sub from_env {

t/08-web-update-coverage.t  view on Meta::CPAN

my ( $root_index_code, undef, undef, $root_index_headers ) = @{ $app->handle( path => '/', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
is( $root_index_code, 302, 'root route redirects to the saved index page when it exists' );
is( $root_index_headers->{Location}, '/app/index', 'root route redirects to the canonical saved index bookmark path' );
unlink $store->page_file('index') or die "Unable to remove temporary index bookmark: $!";

my ( $apps_code, undef, undef, $apps_headers ) = @{ $app->handle( path => '/apps', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
is( $apps_code, 302, '/apps redirects to default index bookmark' );
is( $apps_headers->{Location}, '/app/index', '/apps uses index bookmark as default target' );

my $token = uri_escape( $store->encode_page($page) );
my ( $blocked_code, $blocked_type, $blocked_body ) = @{ $app->handle( path => '/', query => "token=$token", remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
is( $blocked_code, 403, 'transient edit route is denied by default' );
like( $blocked_type, qr/text\/plain/, 'denied transient edit route returns plain text' );
like( $blocked_body, qr/Transient token URLs are disabled/, 'denied transient edit route explains the policy' );

local $ENV{DEVELOPER_DASHBOARD_ALLOW_TRANSIENT_URLS} = 1;

my ( $edit_code, $edit_type, $edit_body ) = @{ $app->handle( path => '/', query => "token=$token", remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
is( $edit_code, 200, 'transient edit route responds with success' );
like( $edit_body, qr/<textarea[^>]*name="instruction"/, 'edit route renders editable source textarea' );
unlike( $edit_body, qr/request_host|request_path|request_remote_addr/, 'edit route does not persist synthetic request metadata into source' );

my ( $render_code, undef, $render_body ) = @{ $app->handle( path => '/', query => "mode=render&token=$token", remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
is( $render_code, 200, 'transient render route responds with success' );

t/10-extension-action-docker.t  view on Meta::CPAN

    permissions => {},
);
my $transient_page = $transient;
eval {
    $actions->run_page_action(
        action => { id => 'run', label => 'Run', kind => 'command', command => 'printf nope' },
        page   => $transient_page,
        source => 'transient',
    );
};
like( $@, qr/not trusted/, 'transient encoded page command action is blocked by default' );

eval {
    $actions->run_page_action(
        action => { id => 'bad', kind => 'weird' },
        page   => $saved_page,
        source => 'saved',
    );
};
like( $@, qr/Unsupported action kind/, 'unsupported action kinds are rejected' );

t/10-extension-action-docker.t  view on Meta::CPAN

    actions => [
        { id => 'state', label => 'State', kind => 'builtin', builtin => 'page.state', safe => 1 },
    ],
    state => { beta => 'two' },
);
my $token = $actions->encode_action_payload(
    action => { id => 'state', label => 'State', kind => 'builtin', builtin => 'page.state', safe => 1 },
    page   => $transient_safe,
    source => 'transient',
);
my ( $transient_blocked_code, undef, $transient_blocked_body ) = @{ $app->handle(
    path        => '/action',
    method      => 'POST',
    query       => 'atoken=' . uri_escape($token),
    remote_addr => '127.0.0.1',
    headers     => { host => '127.0.0.1' },
) };
is( $transient_blocked_code, 403, 'transient encoded builtin action route is denied by default' );
like( $transient_blocked_body, qr/Transient token URLs are disabled/, 'transient encoded builtin action denial explains the policy' );

{
    local $ENV{DEVELOPER_DASHBOARD_ALLOW_TRANSIENT_URLS} = 1;
    my ( $transient_code, $transient_type, $transient_body ) = @{ $app->handle(
        path        => '/action',
        method      => 'POST',
        query       => 'atoken=' . uri_escape($token),
        remote_addr => '127.0.0.1',
        headers     => { host => '127.0.0.1' },
    ) };

t/11-coverage-closure.t  view on Meta::CPAN

    cwd        => $repo,
    background => 1,
    timeout_ms => 1000,
);
ok( $background_result->{pid} > 0, 'background action forks a child process' );
waitpid( $background_result->{pid}, 0 );
ok( !kill( 0, $background_result->{pid} ), 'background action child exits cleanly after running' );

ok(
    !$actions->_is_action_trusted(
        action => { id => 'blocked' },
        page   => Developer::Dashboard::PageDocument->new( permissions => { allow_untrusted_actions => 1, trusted_actions => ['other'] } ),
        source => 'transient',
    ),
    'action runner rejects transient actions missing from trusted_actions allowlist',
);
ok(
    !$actions->_is_action_trusted(
        action => { id => 'blocked' },
        page   => Developer::Dashboard::PageDocument->new( permissions => { allow_untrusted_actions => 1 } ),
        source => 'transient',
    ),
    'action runner rejects transient actions without a trusted_actions array',
);

my $collector = Developer::Dashboard::Collector->new( paths => $paths );
my $indicators = Developer::Dashboard::IndicatorStore->new( paths => $paths );
my $runner = Developer::Dashboard::CollectorRunner->new(
    collectors => $collector,

t/14-coverage-closure-extra.t  view on Meta::CPAN

        );
    }

    my $transient_action_page = Developer::Dashboard::PageDocument->new(
        title       => 'Transient Action',
        state       => { value => 'hello' },
        actions     => [ { id => 'page-state', kind => 'builtin', builtin => 'page.state' } ],
        permissions => { allow_untrusted_actions => 1 },
    );
    my $token = $store->encode_page($transient_action_page);
    my ( $blocked_status, $blocked_type, $blocked_body ) = @{ $app->handle(
        path        => '/action',
        method      => 'POST',
        query       => '',
        body        => 'token=' . uri_escape($token) . '&id=page-state',
        remote_addr => '127.0.0.1',
        headers     => { host => '127.0.0.1' },
    ) };
    is( $blocked_status, 403, 'transient action fallback route is denied by default' );
    like( $blocked_type, qr/text\/plain/, 'transient action fallback denial returns plain-text content type' );
    like( $blocked_body, qr/Transient token URLs are disabled/, 'transient action fallback denial explains the policy' );

    local $ENV{DEVELOPER_DASHBOARD_ALLOW_TRANSIENT_URLS} = 1;
    my ( $status, $type, $body ) = @{ $app->handle(
        path        => '/action',
        method      => 'POST',
        query       => '',
        body        => 'token=' . uri_escape($token) . '&id=page-state',
        remote_addr => '127.0.0.1',
        headers     => { host => '127.0.0.1' },
    ) };

t/38-web-no-editor-browser.t  view on Meta::CPAN

=head1 PURPOSE

This test is the executable browser regression contract for the no-editor web
mode. Use it when changing served bookmark chrome, bookmark editor access
rules, or any route that is supposed to stay read-only in browser mode.

=head1 WHY IT EXISTS

It exists because a cosmetic-only lock would be too weak here. The browser can
hide links while the real edit and source routes still exist, so this test
keeps one real Chromium render path and a couple of blocked sad paths under
the repository gate.

=head1 WHEN TO USE

Use this file when changing C<dashboard serve>, bookmark editor exposure, top
chrome links, or read-only browser behavior.

=head1 HOW TO USE

Run it directly with C<prove -lv t/38-web-no-editor-browser.t> when Chromium
is available. It starts an isolated foreground dashboard server in
C<--no-editor> mode, dumps the rendered DOM for one saved page, then checks
the blocked edit and source routes through the same browser.

=head1 WHAT USES IT

Developers during TDD, the full repository suite, and release verification all
use this test to keep the read-only browser promise honest.

=head1 EXAMPLES

Example 1:

t/web_app_static_files.t  view on Meta::CPAN

{
    my $app = create_mock_app();
    my $ct = $app->_get_content_type('others', 'unknown.xyz');
    is($ct, 'application/octet-stream', 'Unknown content type is octet-stream');
}

# Test: _serve_static_file with directory traversal (security)
{
    my $app = create_mock_app();
    my $response = $app->_serve_static_file('js', '../../../etc/passwd');
    is($response->[0], 400, 'Directory traversal blocked');
}

# Test: _serve_static_file with nonexistent file
{
    my $app = create_mock_app();
    my $response = $app->_serve_static_file('js', 'nonexistent.js');
    is($response->[0], 404, 'Nonexistent file returns 404');
}

# Test: built-in jquery shim route exists for bookmark compatibility



( run in 1.330 second using v1.01-cache-2.11-cpan-39bf76dae61 )