Product Module Client Management

4 vues Markdown

Five opt-in members decide what a customer sees and may do on a product service. They are the tab, the gauges, the shortcuts, the actions and the view flag.

Overview

A product module exposes nothing to the customer by default. The five members below are opt in and type agnostic, so filling them needs no core change.

The overview tab shows usage rings and account rows from client_overview_data(); the management tab shows your own dashboard page. Both can carry gauges, so the module has to know which one it is filling.

has_client_management() The tab gate.
client_overview_data() Gauges and account rows for the overview tab.
client_quick_actions() Shortcut buttons on the overview.
client_callable_methods The allowlist of customer-runnable names.
$client_area True only in the customer view of the dashboard, false in the admin view.

Prerequisites

  • A working product module: Writing a Product Module.
  • A service of type special or software, active, with a module bound. Every other state, suspended included, is refused first.
  • Test as the customer; the admin view never sets the flag.

Structure

What you wantWhat the module doesDefault
No customer screen at allNothingThe default
A management tabShip pages/dashboard.php or define page_dashboard()No tab
No tab, dashboard for admins onlyOverride has_client_management() to return falseTab appears
Usage gauges on the overviewFill client_overview_data()Empty, no gauges
Shortcuts on the overviewFill client_quick_actions()Empty, no shortcuts
A runnable actionDeclare it in $client_callable_methods, write handle_{name}()Refused
An action over GET (a download)Also list it in $client_readonly_methodsToken and POST
Hide a dashboard block from customersCheck $client_area around itShown to both
the request chain
Management tab clicked
  └─ GET use_module_method-less request  ->  ClientServices::get_management_details
       ├─ owner + type + active-state check
       ├─ client_module_instance()        ->  $client_area = true, $area_link set
       ├─ ProductModule::service_management_page()  ->  get_page('dashboard')
       └─ inline script tags split out, evaluated separately by the theme bridge

Overview tab
  └─ the services controller, for special and software services
       ├─ client_overview_data()   ->  usage rings + account rows
       └─ client_quick_actions()   ->  shortcut buttons

A shortcut or a dashboard button
  └─ POST use_module_method  ->  allowlist  ->  handle_{name}()

Walkthrough

Open the Management Tab

Shipping a dashboard page is the opt in: the gate looks for the file.

  1. Create pages/dashboard.php. Inside it, $module is the live instance.
  2. For an admin-only dashboard, override the gate. The admin service page still shows it.
  3. The theme bridge strips inline scripts out of the HTML and evaluates them separately.

Fill the Overview

client_overview_data() returns three lists. Return an empty array while nothing is provisioned: the overview then shows no gauges.

  1. gauges: one entry per limited resource, zero or below meaning unlimited.
  2. resources: optional counters — the used and total shape, or a single value that is not a ratio.
  3. account: identity rows. The service password is injected for you.

Expose an Action

Two things are required, neither alone: the name in the allowlist and a handle_ prefixed method.

  1. Declare public array $client_callable_methods = ['reset_usage'];, without the prefix.
  2. Write public function handle_reset_usage(): array — no arguments; read from the request and the service.
  3. Build the API client inside the handler: a customer call has nothing set up for it.

Avoid Showing Everything Twice

The same file also serves the admin service page, which has no overview tab. Hide duplicated blocks behind $client_area rather than deleting them.

Reference

The Five Members

exact signatures
// Declared on ProductModule; override what you need.
public bool  $client_area            = false;   // set to true only by the client renderer
public array $client_callable_methods = [];     // names WITHOUT the handle_ prefix
public array $client_readonly_methods = [];     // a subset of the above, GET-safe

public function has_client_management(): bool;
public function client_overview_data(): array;
public function client_quick_actions(int $limit = 8): array;

// Your handler for a declared callable. No parameters; returns the JSON payload.
public function handle_reset_usage(): array;

client_overview_data() Shape

the structure the controller reads
return [
    'gauges' => [
        [
            'key'   => 'storage',
            'label' => $this->lang['storage-usage'],
            'icon'  => 'bi bi-hdd',
            'used'  => 4.5,
            'total' => 20,          // 0 or below means unlimited; pass -1 for clarity
            'unit'  => 'GB',
        ],
    ],
    'resources' => [
        // Ratio form: same keys as a gauge.
        ['key' => 'seats', 'label' => 'Seats', 'icon' => 'bi bi-people', 'used' => 3, 'total' => 10, 'unit' => ''],
        // Value form: a single reading with no limit.
        ['key' => 'region', 'label' => 'Region', 'icon' => 'bi bi-globe', 'value' => 'eu-west'],
    ],
    'account' => [
        ['key' => 'account_id', 'label' => 'Account ID', 'type' => 'text', 'copyable' => true, 'value' => 'ac_1042'],
        ['key' => 'username',   'label' => 'Username',   'type' => 'text', 'copyable' => true, 'value' => 'acme'],
        ['key' => 'status',     'label' => 'Status',     'type' => 'badge', 'badge_color' => 'success', 'value' => 'Active'],
        ['key' => 'console',    'label' => 'Console',    'type' => 'link',  'value' => 'https://panel.example.com'],
    ],
];
gauges: total Zero or below is unlimited: the gauge moves to the specification list with the infinity sign.
gauges: no percentage Send raw numbers. The theme computes the ratio and the colour tier; a pre-computed percentage is ignored.
resources: total of zero Zero is a real limit here, unlike a gauge. Only a negative total is unlimited.
account: type text, link, badge or password. copyable adds a copy button; a link row takes the full width.
account: badge_color success, warning, danger or the default. The icon comes from the colour.
account: the password row Injected after the row keyed username, or appended when there is no such row.
every value is shown as given Format dates and numbers first: a raw provider timestamp reaches the customer as one.

client_quick_actions() Shape

signature and return
public function client_quick_actions(int $limit = 8): array
{
    $actions = [
        // action => true: runs the handler in place, through the callable allowlist.
        ['key' => 'reset_usage', 'label' => $this->lang['action-reset-usage'],
         'icon' => 'bi bi-arrow-counterclockwise', 'action' => true, 'method' => 'reset_usage'],

        // action => false: navigates to a page inside the management tab instead.
        ['key' => 'backups', 'label' => $this->lang['action-backups'],
         'icon' => 'bi bi-archive', 'action' => false, 'method' => 'backups'],
    ];

    // Honour the limit: the caller decides how many fit.
    return array_slice($actions, 0, $limit);
}

Before Your Handler Runs

Most "my action does nothing" reports are one of these refusals.

the gate, in order
// ClientServices::use_module_method, reduced to the decisions.
$service = $this->owned_managed_service($uid);      // owner, type, module and active-state check
$method  = (string) Filter::init("REQUEST/method", "route");

$module = $this->client_module_instance($service);  // sets $client_area and $area_link

$handleMethod = 'handle_' . str_replace('-', '_', $method);
$isTool       = in_array($method, ['tool_action', 'tool_table', 'sso_panel_login'], true);

// BOTH conditions: declared in the allowlist AND the handler actually exists.
$isClientCallable = !$isTool
    && in_array($method, $module->client_callable_methods ?? [], true)
    && method_exists($module, $handleMethod);

if (!$isTool && !$isClientCallable)
    throw new \Exception(Language::gc("website/services/err-invalid"));

// Read-only callables skip the token and the active-service requirement.
$isReadonly = $isClientCallable && in_array($method, $module->client_readonly_methods ?? [], true);
$mutates    = ($method === 'tool_action' || $isClientCallable) && !$isReadonly;

if ($mutates && !\Validation::verify_csrf_token((string) Filter::init("POST/token", "hclear"), "services"))
    throw new \Exception(Language::g("needs/csrf-failed"));

if ($mutates && ($service['status'] ?? '') !== 'active')
    throw new \Exception(Language::gc("website/services/err-invalid"));
ownership, type and module It must belong to the signed-in account, be hosting, server, special or software, and name a module other than none. A service marked with Restrict Service Details is also not found.
the allowlist is not optional An undeclared handler is refused even when it exists, and silently, so it reads like a broken button. That is what keeps handle_create unreachable.
token and live service The owner lookup already demands active, so a suspended service is refused for reads too. Mutations also need a valid token.
gate:service.client_tool Runs immediately before your handler. A listener returning a non empty string vetoes the call with that message.
action:service.client_tool_ran Fires afterwards with the service, the requested method, the resolved method name and the result.
filter:client.service_management_content Filters the finished dashboard HTML by reference, before the inline scripts are split out.

Example

The module half and the dashboard half; the second makes the $client_area decision.

the module
// Declared without the handle_ prefix. Anything absent here is refused.
public array $client_callable_methods = ['reset_usage', 'download_report'];

// Served over a GET link, so exempt from the token and the POST requirement.
public array $client_readonly_methods = ['download_report'];

public function client_overview_data(): array
{
    $remote = $this->fetchRemoteStatus();
    if (!$remote) return [];                       // nothing provisioned yet: no gauges

    $gauges = [];
    if (isset($remote['storage_limit']))
        $gauges[] = [
            'key'   => 'storage',
            'label' => $this->lang['storage-usage'],
            'icon'  => 'bi bi-hdd',
            'used'  => (float) ($remote['used_storage'] ?? 0),
            'total' => (float) $remote['storage_limit'] > 0 ? (float) $remote['storage_limit'] : -1,
            'unit'  => 'GB',
        ];

    $account = [];
    $username = (string) ($this->options['login']['username'] ?? '');

    // The controller injects the password row right after this one.
    if ($username !== '')
        $account[] = ['key' => 'username', 'label' => $this->lang['username'], 'type' => 'text', 'copyable' => true, 'value' => $username];

    $status = (string) ($remote['status'] ?? '');
    if ($status !== '')
        $account[] = [
            'key'         => 'status',
            'label'       => $this->lang['account-status'],
            'type'        => 'badge',
            'badge_color' => $status === 'active' ? 'success' : 'secondary',
            'value'       => $this->lang['status-' . $status] ?? ucfirst($status),
        ];

    if (!$gauges && !$account) return [];

    return ['gauges' => $gauges, 'resources' => [], 'account' => $account];
}

public function handle_reset_usage(): array
{
    // Client-triggered: nothing has prepared the API client for you.
    $this->initApi();

    $accountId = $this->options['config']['id'] ?? '';
    if (!$accountId) throw new Exception($this->lang['err-not-provisioned']);

    $this->api->call('accounts/' . $accountId . '/usage/reset', [], 'POST');

    return ['status' => 'successful', 'message' => $this->lang['usage-reset-ok']];
}
pages/dashboard.php, two audiences
<?php
/** @var ProductModule $module */

// Admin has no Overview tab, so it must keep these blocks. The customer already
// sees the same numbers there, so hide them here rather than deleting them.
if (!($module->client_area ?? false)):
?>
    <div class="row">
        <!-- gauge widgets and the account card -->
    </div>
<?php endif; ?>

<!-- Actions stay in both views: this is the management surface. -->
<button type="button" class="btn btn-primary" id="acmeResetUsage">Reset Usage</button>

<script>
(function () {
    // Null-guard every lookup: a block hidden by $client_area leaves its ids missing,
    // and an uncaught error here takes the whole panel down, not just this button.
    var btn = document.getElementById('acmeResetUsage');
    if (!btn) return;

    btn.addEventListener('click', function () {
        run_module_method('reset_usage');
    });
})();
</script>

Pitfalls

One unguarded element lookup kills the whole panel

A block hidden by $client_area takes its element ids with it, and inline script that calls addEventListener on the missing node throws. The script bridge throws with it, so the customer gets a generic "could not load" message. Guard every lookup.

A shortcut is a shortcut, not the home of an action

Put the action on the dashboard and let the shortcut point at it.

Empty page under the scheduler

Template output is skipped in the scheduled-task context. A command line probe reports a zero length page while the real request returns the full panel. Check over real HTTP or impersonation.

Cet article vous a-t-il été utile ?

Merci pour votre retour !

Besoin d'aide supplémentaire ?

Notre équipe d'assistance est disponible 24h/24 pour tout ce que vous ne trouvez pas ci-dessus.