Feature/vue webhooks (#4655)

This commit is contained in:
Buster "Silver Eagle" Neece 2021-10-06 22:00:53 -05:00 committed by GitHub
parent c81cbdde44
commit 798bfd1eb2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
71 changed files with 1776 additions and 1510 deletions

View File

@ -1,152 +0,0 @@
<?php
/**
* @var array $triggers
* @var App\Environment $environment
* @var App\Http\Router $router
*/
return [
'method' => 'post',
'groups' => [
'api_info' => [
'use_grid' => true,
'elements' => [
'name' => [
'text',
[
'label' => __('Web Hook Name'),
'description' => __(
'Choose a name for this webhook that will help you distinguish it from others. This will only be shown on the administration page.'
),
'required' => true,
'form_group_class' => 'col-md-6',
],
],
'webhook_url' => [
'url',
[
'label' => __('Discord Web Hook URL'),
'description' => __('This URL is provided within the Discord application.'),
'belongsTo' => 'config',
'required' => true,
'form_group_class' => 'col-md-6',
],
],
'triggers' => [
'multiCheckbox',
[
'label' => __('Web Hook Triggers'),
'options' => array_diff_key($triggers, ['listener_lost' => 1, 'listener_gained' => 1]),
'required' => true,
'form_group_class' => 'col-sm-12',
],
],
],
],
'message' => [
'use_grid' => true,
'legend' => __('Customize Message'),
'legend_class' => 'd-none',
'description' => __(
'Variables are in the form of <code>{{ var.name }}</code>. All values in the <a href="%s" target="_blank">Now Playing API response</a> are avaliable for use. Any empty fields are ignored.',
$router->named('api:nowplaying:index')
),
'elements' => [
'content' => [
'text',
[
'label' => __('Main Message Content'),
'belongsTo' => 'config',
'default' => sprintf(__('Now playing on %s:'), '{{ station.name }}'),
'form_group_class' => 'col-md-6',
],
],
'title' => [
'text',
[
'label' => __('Title'),
'belongsTo' => 'config',
'default' => '{{ now_playing.song.title }}',
'form_group_class' => 'col-md-6',
],
],
'description' => [
'text',
[
'label' => __('Description'),
'belongsTo' => 'config',
'default' => '{{ now_playing.song.artist }}',
'form_group_class' => 'col-md-6',
],
],
'url' => [
'text',
[
'label' => __('URL'),
'belongsTo' => 'config',
'default' => '{{ station.listen_url }}',
'form_group_class' => 'col-md-6',
],
],
'author' => [
'text',
[
'label' => __('Author Name'),
'belongsTo' => 'config',
'default' => '{{ live.streamer_name }}',
'form_group_class' => 'col-md-6',
],
],
'thumbnail' => [
'text',
[
'label' => __('Thumbnail Image URL'),
'belongsTo' => 'config',
'default' => '{{ now_playing.song.art }}',
'form_group_class' => 'col-md-6',
],
],
'footer' => [
'text',
[
'label' => __('Footer Text'),
'belongsTo' => 'config',
'default' => sprintf(__('Powered by %s'), $environment->getAppName()),
'form_group_class' => 'col-md-6',
],
],
],
],
'submit_grp' => [
'elements' => [
'submit' => [
'submit',
[
'type' => 'submit',
'label' => __('Save Changes'),
'class' => 'ui-button btn-lg btn-primary',
],
],
],
],
],
];

View File

@ -1,96 +0,0 @@
<?php
/**
* @var array $triggers
* @var App\Environment $environment
* @var App\Http\Router $router
*/
return [
'method' => 'post',
'groups' => [
'message_grp' => [
'use_grid' => true,
'elements' => [
'name' => [
'text',
[
'label' => __('Web Hook Name'),
'description' => __(
'Choose a name for this webhook that will help you distinguish it from others. This will only be shown on the administration page.'
),
'required' => true,
'form_group_class' => 'col-md-6',
],
],
'triggers' => [
'multiCheckbox',
[
'label' => __('Web Hook Triggers'),
'options' => $triggers,
'required' => true,
'form_group_class' => 'col-sm-12',
],
],
'to' => [
'text',
[
'label' => __('Message Recipient(s)'),
'belongsTo' => 'config',
'required' => true,
'description' => __('E-mail addresses can be separated by commas.'),
'form_group_class' => 'col-sm-6',
],
],
'subject' => [
'text',
[
'label' => __('Message Subject'),
'belongsTo' => 'config',
'required' => true,
'description' => __(
'Variables are in the form of <code>{{ var.name }}</code>. All values in the <a href="%s" target="_blank">Now Playing API response</a> are avaliable for use. Any empty fields are ignored.',
$router->named('api:nowplaying:index')
),
'form_group_class' => 'col-sm-6',
],
],
'message' => [
'textarea',
[
'label' => __('Message Body'),
'belongsTo' => 'config',
'required' => true,
'description' => __(
'Variables are in the form of <code>{{ var.name }}</code>. All values in the <a href="%s" target="_blank">Now Playing API response</a> are avaliable for use. Any empty fields are ignored.',
$router->named('api:nowplaying:index')
),
'form_group_class' => 'col-sm-12',
],
],
],
],
'submit_grp' => [
'elements' => [
'submit' => [
'submit',
[
'type' => 'submit',
'label' => __('Save Changes'),
'class' => 'ui-button btn-lg btn-primary',
],
],
],
],
],
];

View File

@ -1,108 +0,0 @@
<?php
/**
* @var array $triggers
* @var App\Environment $environment
* @var App\Http\Router $router
*/
return [
'method' => 'post',
'groups' => [
'api_info' => [
'use_grid' => true,
'legend' => __('Web Hook Details'),
'legend_class' => 'd-none',
'description' => sprintf(
__(
'Web hooks automatically send a HTTP POST request to the URL you specify to
notify it any time one of the triggers you specify occurs on your station. The body of the POST message
is the exact same as the <a href="%s" target="_blank">Now Playing API response</a> for your station.
In order to process quickly, web hooks have a short timeout, so the responding service should be
optimized to handle the request in under 2 seconds.'
),
$router->named('api:nowplaying:index')
),
'elements' => [
'name' => [
'text',
[
'label' => __('Web Hook Name'),
'description' => __(
'Choose a name for this webhook that will help you distinguish it from others. This will only be shown on the administration page.'
),
'required' => true,
'form_group_class' => 'col-md-6',
],
],
'webhook_url' => [
'url',
[
'label' => __('Web Hook URL'),
'description' => __(
'The URL that will receive the POST messages any time an event is triggered.'
),
'belongsTo' => 'config',
'required' => true,
'label_class' => 'mb-2',
'form_group_class' => 'col-md-6 mt-1',
],
],
'basic_auth_username' => [
'text',
[
'label' => __('Optional: HTTP Basic Authentication Username'),
'description' => __(
'If your web hook requires HTTP basic authentication, provide the username here.'
),
'belongsTo' => 'config',
'form_group_class' => 'col-md-6',
],
],
'basic_auth_password' => [
'text',
[
'label' => __('Optional: HTTP Basic Authentication Password'),
'description' => __(
'If your web hook requires HTTP basic authentication, provide the password here.'
),
'belongsTo' => 'config',
'form_group_class' => 'col-md-6',
],
],
'triggers' => [
'multiCheckbox',
[
'label' => __('Web Hook Triggers'),
'options' => $triggers,
'required' => true,
'form_group_class' => 'col-sm-12',
],
],
],
],
'submit_grp' => [
'elements' => [
'submit' => [
'submit',
[
'type' => 'submit',
'label' => __('Save Changes'),
'class' => 'ui-button btn-lg btn-primary',
],
],
],
],
],
];

View File

@ -1,52 +0,0 @@
<?php
/**
* @var array $triggers
* @var App\Environment $environment
* @var App\Http\Router $router
*/
return [
'method' => 'post',
'groups' => [
[
'use_grid' => true,
'elements' => [
'name' => [
'text',
[
'label' => __('Web Hook Name'),
'description' => __(
'Choose a name for this webhook that will help you distinguish it from others. This will only be shown on the administration page.'
),
'required' => true,
'form_group_class' => 'col-md-6',
],
],
'tracking_id' => [
'text',
[
'label' => __('GA Property Tracking ID'),
'description' => __('The property ID used to track live listeners.'),
'belongsTo' => 'config',
'required' => true,
'form_group_class' => 'col-md-6',
],
],
'submit' => [
'submit',
[
'type' => 'submit',
'label' => __('Save Changes'),
'class' => 'ui-button btn-lg btn-primary',
'form_group_class' => 'col-sm-12',
],
],
],
],
],
];

View File

@ -1,73 +0,0 @@
<?php
/**
* @var array $triggers
* @var App\Environment $environment
* @var App\Http\Router $router
*/
return [
'method' => 'post',
'groups' => [
[
'use_grid' => true,
'elements' => [
'name' => [
'text',
[
'label' => __('Web Hook Name'),
'description' => __(
'Choose a name for this webhook that will help you distinguish it from others. This will only be shown on the administration page.'
),
'required' => true,
'form_group_class' => 'col-md-6',
],
],
'matomo_url' => [
'url',
[
'label' => __('Matomo Installation Base URL'),
'description' => __('The full base URL of your Matomo installation.'),
'belongsTo' => 'config',
'required' => true,
'form_group_class' => 'col-md-12',
],
],
'site_id' => [
'text',
[
'label' => __('Matomo Site ID'),
'description' => __('The numeric site ID for this site.'),
'belongsTo' => 'config',
'required' => true,
'form_group_class' => 'col-md-6',
],
],
'token' => [
'text',
[
'label' => __('Matomo API Token'),
'description' => __('Optionally supply an API token to allow IP address overriding.'),
'belongsTo' => 'config',
'form_group_class' => 'col-md-6',
],
],
'submit' => [
'submit',
[
'type' => 'submit',
'label' => __('Save Changes'),
'class' => 'ui-button btn-lg btn-primary',
'form_group_class' => 'col-sm-12',
],
],
],
],
],
];

View File

@ -1,146 +0,0 @@
<?php
/**
* @var array $triggers
* @var App\Environment $environment
* @var App\Http\Router $router
*/
return [
'method' => 'post',
'groups' => [
'api_info' => [
'use_grid' => true,
'elements' => [
'name' => [
'text',
[
'label' => __('Web Hook Name'),
'description' => __(
'Choose a name for this webhook that will help you distinguish it from others. This will only be shown on the administration page.'
),
'required' => true,
'form_group_class' => 'col-md-6',
],
],
'bot_token' => [
'text',
[
'label' => __('Bot Token'),
'description' => __(
'See the <a href="%s" target="_blank">Telegram Documentation</a> for more details.',
'https://core.telegram.org/bots#botfather'
),
'belongsTo' => 'config',
'required' => true,
'form_group_class' => 'col-md-6',
],
],
'chat_id' => [
'text',
[
'label' => __('Chat ID'),
'description' => __(
'Unique identifier for the target chat or username of the target channel (in the format @channelusername).'
),
'belongsTo' => 'config',
'required' => true,
'form_group_class' => 'col-md-6',
],
],
'api' => [
'text',
[
'label' => __('Custom API Base URL'),
'label_class' => 'advanced',
'description' => __(
'Leave blank to use the default Telegram API URL (recommended). Specify the full URL, like <code>https://api.pwrtelegram.xyz/</code>.'
),
'belongsTo' => 'config',
'form_group_class' => 'col-md-6',
],
],
'triggers' => [
'multiCheckbox',
[
'label' => __('Web Hook Triggers'),
'options' => array_diff_key($triggers, ['listener_lost' => 1, 'listener_gained' => 1]),
'required' => true,
'form_group_class' => 'col-md-6',
],
],
],
],
'message' => [
'use_grid' => true,
'legend' => __('Customize Message'),
'legend_class' => 'd-none',
'description' => sprintf(
__(
'Variables are in the form of <code>{{ var.name }}</code>. All values in the <a href="%s" target="_blank">Now Playing API response</a> are avaliable for use. Any empty fields are ignored.'
),
$router->named('api:nowplaying:index')
),
'elements' => [
'text' => [
'textarea',
[
'label' => __('Main Message Content'),
'belongsTo' => 'config',
'default' => sprintf(
__('Now playing on %s: %s by %s! Tune in now.'),
'{{ station.name }}',
'{{ now_playing.song.title }}',
'{{ now_playing.song.artist }}'
),
'required' => true,
'form_group_class' => 'col-sm-12',
],
],
'parse_mode' => [
'radio',
[
'label' => __('Message parsing mode'),
'description' => __(
'See the <a href="%s" target="_blank">Telegram Documentation</a> for more details.',
'https://core.telegram.org/bots/api#sendmessage'
),
'default' => 'Markdown',
'options' => [
'Markdown' => 'Markdown',
'HTML' => 'HTML',
],
'form_group_class' => 'col-sm-12',
],
],
],
],
'submit_grp' => [
'elements' => [
'submit' => [
'submit',
[
'type' => 'submit',
'label' => __('Save Changes'),
'class' => 'ui-button btn-lg btn-primary',
],
],
],
],
],
];

View File

@ -1,72 +0,0 @@
<?php
/**
* @var array $triggers
* @var App\Environment $environment
* @var App\Http\Router $router
*/
return [
'method' => 'post',
'groups' => [
[
'use_grid' => true,
'elements' => [
'name' => [
'text',
[
'label' => __('Web Hook Name'),
'description' => __(
'Choose a name for this webhook that will help you distinguish it from others. This will only be shown on the administration page.'
),
'required' => true,
'form_group_class' => 'col-md-6',
],
],
'station_id' => [
'text',
[
'label' => __('TuneIn Station ID'),
'description' => __('The station ID will be a numeric string that starts with the letter S.'),
'belongsTo' => 'config',
'required' => true,
'form_group_class' => 'col-md-6',
],
],
'partner_id' => [
'text',
[
'label' => __('TuneIn Partner ID'),
'belongsTo' => 'config',
'required' => true,
'form_group_class' => 'col-md-6',
],
],
'partner_key' => [
'text',
[
'label' => __('TuneIn Partner Key'),
'belongsTo' => 'config',
'required' => true,
'form_group_class' => 'col-md-6',
],
],
'submit' => [
'submit',
[
'type' => 'submit',
'label' => __('Save Changes'),
'class' => 'ui-button btn-lg btn-primary',
'form_group_class' => 'col-sm-12',
],
],
],
],
],
];

View File

@ -1,161 +0,0 @@
<?php
/**
* @var array $triggers
* @var App\Environment $environment
* @var App\Http\Router $router
*/
return [
'method' => 'post',
'groups' => [
'api_info' => [
'use_grid' => true,
'legend' => __('Twitter Account Details'),
'legend_class' => 'd-none',
'description' => __(
'Steps for configuring a Twitter application:<br>
<ol type="1">
<li>Create a new app on the <a href="%s" target="_blank">Twitter Applications site</a>.
Use this installation\'s base URL as the application URL.</li>
<li>In the newly created application, click the "Keys and Access Tokens" tab.</li>
<li>At the bottom of the page, click "Create my access token".</li>
</ol>
<p>Once these steps are completed, enter the information from the "Keys and Access Tokens" page into the fields below.</p>',
'https://developer.twitter.com/en/apps'
),
'elements' => [
'consumer_key' => [
'text',
[
'label' => __('Consumer Key (API Key)'),
'belongsTo' => 'config',
'required' => true,
'form_group_class' => 'col-md-6',
],
],
'consumer_secret' => [
'text',
[
'label' => __('Consumer Secret (API Secret)'),
'belongsTo' => 'config',
'required' => true,
'form_group_class' => 'col-md-6',
],
],
'token' => [
'text',
[
'label' => __('Access Token'),
'belongsTo' => 'config',
'required' => true,
'form_group_class' => 'col-md-6',
],
],
'token_secret' => [
'text',
[
'label' => __('Access Token Secret'),
'belongsTo' => 'config',
'required' => true,
'form_group_class' => 'col-md-6',
],
],
'rate_limit' => [
'select',
[
'label' => __('Only Send One Tweet Every...'),
'belongsTo' => 'config',
'default' => 0,
'choices' => [
0 => __('No Limit'),
15 => __('%d seconds', 15),
30 => __('%d seconds', 30),
60 => __('%d seconds', 60),
120 => __('%d minutes', 2),
300 => __('%d minutes', 5),
600 => __('%d minutes', 10),
900 => __('%d minutes', 15),
1800 => __('%d minutes', 30),
3600 => __('%d minutes', 60),
],
'form_group_class' => 'col-sm-12',
],
],
],
],
'message_grp' => [
'use_grid' => true,
'elements' => [
'name' => [
'text',
[
'label' => __('Web Hook Name'),
'description' => __(
'Choose a name for this webhook that will help you distinguish it from others. This will only be shown on the administration page.'
),
'required' => true,
'form_group_class' => 'col-md-6',
],
],
'triggers' => [
'multiCheckbox',
[
'label' => __('Web Hook Triggers'),
'options' => $triggers,
'required' => true,
'form_group_class' => 'col-sm-12',
],
],
'message' => [
'textarea',
[
'label' => __('Message Body'),
'belongsTo' => 'config',
'required' => true,
'default' => __(
'Now playing on %s: %s by %s! Tune in now: %s',
'{{ station.name }}',
'{{ now_playing.song.title }}',
'{{ now_playing.song.artist }}',
'{{ station.public_player_url }}'
),
'description' => __(
'Variables are in the form of <code>{{ var.name }}</code>. All values in the <a href="%s" target="_blank">Now Playing API response</a> are avaliable for use. Any empty fields are ignored.',
$router->named('api:nowplaying:index')
),
'form_group_class' => 'col-sm-12',
],
],
],
],
'submit_grp' => [
'elements' => [
'submit' => [
'submit',
[
'type' => 'submit',
'label' => __('Save Changes'),
'class' => 'ui-button btn-lg btn-primary',
],
],
],
],
],
];

View File

@ -9,9 +9,6 @@ return [
Message\AddNewMediaMessage::class => Task\CheckMediaTask::class,
Message\ReprocessMediaMessage::class => Task\CheckMediaTask::class,
Message\AddNewPodcastMediaMessage::class => Task\CheckPodcastMediaTask::class,
Message\ReprocessPodcastMediaMessage::class => Task\CheckPodcastMediaTask::class,
Message\WritePlaylistFileMessage::class => Liquidsoap\ConfigWriter::class,
Message\UpdateNowPlayingMessage::class => Task\NowPlayingTask::class,
@ -21,6 +18,7 @@ return [
Message\RunSyncTaskMessage::class => App\Sync\Runner::class,
Message\DispatchWebhookMessage::class => App\Webhook\Dispatcher::class,
Message\TestWebhookMessage::class => App\Webhook\Dispatcher::class,
Mailer\Messenger\SendEmailMessage::class => Mailer\Messenger\MessageHandler::class,
];

View File

@ -667,6 +667,26 @@ return static function (RouteCollectorProxy $app) {
$group->post('/restart', Controller\Api\Stations\ServicesController::class . ':restartAction')
->setName('api:stations:restart')
->add(new Middleware\Permissions(Acl::STATION_BROADCASTING, true));
$group->group(
'/webhook/{id}',
function (RouteCollectorProxy $group) {
$group->put(
'/toggle',
Controller\Api\Stations\Webhooks\ToggleAction::class
)->setName('api:stations:webhook:toggle');
$group->put(
'/test',
Controller\Api\Stations\Webhooks\TestAction::class
)->setName('api:stations:webhook:test');
$group->get(
'/test-log/{path}',
Controller\Api\Stations\Webhooks\TestLogAction::class
)->setName('api:stations:webhook:test-log');
}
)->add(new Middleware\Permissions(Acl::STATION_WEB_HOOKS, true));
}
)->add(Middleware\RequireStation::class)
->add(Middleware\GetStation::class);

View File

@ -138,36 +138,9 @@ return static function (RouteCollectorProxy $app) {
->setName('stations:streamers:index')
->add(new Middleware\Permissions(Acl::STATION_STREAMERS, true));
$group->group(
'/webhooks',
function (RouteCollectorProxy $group) {
$group->get('', Controller\Stations\WebhooksController::class . ':indexAction')
->setName('stations:webhooks:index');
$group->map(
['GET', 'POST'],
'/edit/{id}',
Controller\Stations\WebhooksController::class . ':editAction'
)
->setName('stations:webhooks:edit');
$group->map(
['GET', 'POST'],
'/add[/{type}]',
Controller\Stations\WebhooksController::class . ':addAction'
)
->setName('stations:webhooks:add');
$group->get('/toggle/{id}/{csrf}', Controller\Stations\WebhooksController::class . ':toggleAction')
->setName('stations:webhooks:toggle');
$group->get('/test/{id}/{csrf}', Controller\Stations\WebhooksController::class . ':testAction')
->setName('stations:webhooks:test');
$group->get('/delete/{id}/{csrf}', Controller\Stations\WebhooksController::class . ':deleteAction')
->setName('stations:webhooks:delete');
}
)->add(new Middleware\Permissions(Acl::STATION_WEB_HOOKS, true));
$group->get('/webhooks', Controller\Stations\WebhooksAction::class)
->setName('stations:webhooks:index')
->add(new Middleware\Permissions(Acl::STATION_WEB_HOOKS, true));
}
)
->add(Middleware\Module\Stations::class)

View File

@ -6,58 +6,74 @@
use App\Entity\StationWebhook;
use App\Webhook\Connector;
$triggers = [
StationWebhook::TRIGGER_SONG_CHANGED => __('Any time the currently playing song changes'),
StationWebhook::TRIGGER_LISTENER_GAINED => __('Any time the listener count increases'),
StationWebhook::TRIGGER_LISTENER_LOST => __('Any time the listener count decreases'),
StationWebhook::TRIGGER_LIVE_CONNECT => __('Any time a live streamer/DJ connects to the stream'),
StationWebhook::TRIGGER_LIVE_DISCONNECT => __('Any time a live streamer/DJ disconnects from the stream'),
StationWebhook::TRIGGER_STATION_OFFLINE => __('When the station broadcast goes offline.'),
StationWebhook::TRIGGER_STATION_ONLINE => __('When the station broadcast comes online.'),
];
$allTriggers = array_keys($triggers);
$allTriggersExceptListeners = array_diff($allTriggers, [
StationWebhook::TRIGGER_LISTENER_GAINED,
StationWebhook::TRIGGER_LISTENER_LOST,
]);
return [
'webhooks' => [
Connector\Generic::NAME => [
'class' => Connector\Generic::class,
'name' => __('Generic Web Hook'),
'description' => __('Automatically send a message to any URL when your station data changes.'),
'triggers' => $allTriggers,
],
Connector\Email::NAME => [
'class' => Connector\Email::class,
'name' => __('Send E-mail'),
'description' => __('Send an e-mail to specified address(es).'),
'triggers' => $allTriggers,
],
Connector\TuneIn::NAME => [
'class' => Connector\TuneIn::class,
'name' => __('TuneIn AIR'),
'description' => __('Send song metadata changes to TuneIn.'),
'triggers' => [],
],
Connector\Discord::NAME => [
'class' => Connector\Discord::class,
'name' => __('Discord Webhook'),
'description' => __('Automatically send a customized message to your Discord server.'),
'triggers' => $allTriggersExceptListeners,
],
Connector\Telegram::NAME => [
'class' => Connector\Telegram::class,
'name' => __('Telegram Chat Message'),
'description' => __('Use the Telegram Bot API to send a message to a channel.'),
'triggers' => $allTriggersExceptListeners,
],
Connector\Twitter::NAME => [
'class' => Connector\Twitter::class,
'name' => __('Twitter Post'),
'description' => __('Automatically send a tweet.'),
'triggers' => $allTriggers,
],
Connector\GoogleAnalytics::NAME => [
'class' => Connector\GoogleAnalytics::class,
'name' => __('Google Analytics Integration'),
'description' => __('Send stream listener details to Google Analytics.'),
'triggers' => [],
],
Connector\MatomoAnalytics::NAME => [
'class' => Connector\MatomoAnalytics::class,
'name' => __('Matomo Analytics Integration'),
'description' => __('Send stream listener details to Matomo Analytics.'),
'triggers' => [],
],
],
// The triggers that can be selected for a web hook to trigger.
'triggers' => [
StationWebhook::TRIGGER_SONG_CHANGED => __('Any time the currently playing song changes'),
StationWebhook::TRIGGER_LISTENER_GAINED => __('Any time the listener count increases'),
StationWebhook::TRIGGER_LISTENER_LOST => __('Any time the listener count decreases'),
StationWebhook::TRIGGER_LIVE_CONNECT => __('Any time a live streamer/DJ connects to the stream'),
StationWebhook::TRIGGER_LIVE_DISCONNECT => __('Any time a live streamer/DJ disconnects from the stream'),
StationWebhook::TRIGGER_STATION_OFFLINE => __('When the station broadcast goes offline.'),
StationWebhook::TRIGGER_STATION_ONLINE => __('When the station broadcast comes online.'),
],
'triggers' => $triggers,
];

View File

@ -63,6 +63,7 @@
"sweetalert2": "^10.16.6",
"vue": "^2.6.14",
"vue-axios": "^3.3.6",
"vue-clipboard2": "^0.3.3",
"vue-gettext": "^2.1.12",
"vue-loader": "^15.9.8",
"vue-template-compiler": "^2.6.14",
@ -9617,6 +9618,14 @@
"vue": "^ 3.0.0 || ^ 2.0.0"
}
},
"node_modules/vue-clipboard2": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/vue-clipboard2/-/vue-clipboard2-0.3.3.tgz",
"integrity": "sha512-aNWXIL2DKgJyY/1OOeITwAQz1fHaCIGvUFHf9h8UcoQBG5a74MkdhS/xqoYe7DNZdQmZRL+TAdIbtUs9OyVjbw==",
"dependencies": {
"clipboard": "^2.0.0"
}
},
"node_modules/vue-functional-data-merge": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/vue-functional-data-merge/-/vue-functional-data-merge-3.1.0.tgz",
@ -17453,6 +17462,14 @@
"merge-stream": "^2.0.0"
}
},
"vue-clipboard2": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/vue-clipboard2/-/vue-clipboard2-0.3.3.tgz",
"integrity": "sha512-aNWXIL2DKgJyY/1OOeITwAQz1fHaCIGvUFHf9h8UcoQBG5a74MkdhS/xqoYe7DNZdQmZRL+TAdIbtUs9OyVjbw==",
"requires": {
"clipboard": "^2.0.0"
}
},
"vue-functional-data-merge": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/vue-functional-data-merge/-/vue-functional-data-merge-3.1.0.tgz",

View File

@ -66,6 +66,7 @@
"sweetalert2": "^10.16.6",
"vue": "^2.6.14",
"vue-axios": "^3.3.6",
"vue-clipboard2": "^0.3.3",
"vue-gettext": "^2.1.12",
"vue-loader": "^15.9.8",
"vue-template-compiler": "^2.6.14",

View File

@ -1,6 +1,6 @@
<template>
<modal-form ref="modal" :loading="loading" :title="langTitle" :error="error" :disable-save-button="$v.form.$invalid"
@submit="doSubmit">
@submit="doSubmit" @hidden="clearContents">
<admin-custom-fields-form :form="$v.form" :auto-assign-types="autoAssignTypes">
</admin-custom-fields-form>

View File

@ -1,6 +1,6 @@
<template>
<modal-form ref="modal" :loading="loading" :title="langTitle" :error="error" :disable-save-button="$v.form.$invalid"
@submit="doSubmit">
@submit="doSubmit" @hidden="clearContents">
<b-tabs content-class="mt-3">
<admin-permissions-global-form :form="$v.form" :global-permissions="globalPermissions">
@ -74,7 +74,7 @@ export default {
};
});
},
buildSubmitRequest () {
getSubmittableFormData() {
let form = {
name: this.form.name,
permissions: {
@ -87,15 +87,7 @@ export default {
form.permissions.station[row.station_id] = row.permissions;
});
return {
method: (this.isEditMode)
? 'PUT'
: 'POST',
url: (this.isEditMode)
? this.editUrl
: this.createUrl,
data: form
};
return form;
},
}
};

View File

@ -1,6 +1,6 @@
<template>
<modal-form ref="modal" :loading="loading" :title="langTitle" :error="error" :disable-save-button="$v.form.$invalid"
@submit="doSubmit">
@submit="doSubmit" @hidden="clearContents">
<storage-location-form :form="$v.form"></storage-location-form>

View File

@ -65,10 +65,13 @@ export default {
this.close();
});
},
populateForm (data) {
populateForm(data) {
this.form = data;
},
buildSubmitRequest () {
getSubmittableFormData() {
return this.form;
},
buildSubmitRequest() {
return {
method: (this.isEditMode)
? 'PUT'
@ -76,7 +79,7 @@ export default {
url: (this.isEditMode)
? this.editUrl
: this.createUrl,
data: this.form
data: this.getSubmittableFormData()
};
},
doSubmit () {
@ -97,15 +100,17 @@ export default {
this.error = error.response.data.message;
});
},
close () {
close() {
this.$refs.modal.hide();
},
clearContents() {
this.$v.form.$reset();
this.loading = false;
this.error = null;
this.editUrl = null;
this.resetForm();
this.$v.form.$reset();
this.$refs.modal.hide();
}
},
}
};
</script>

View File

@ -1,36 +1,33 @@
<template>
<button ref="btn" class="btn btn-copy btn-link btn-xs" :data-clipboard-target="target" v-bind="$attrs">
<button ref="btn" class="btn btn-copy btn-link btn-xs" @click.prevent="doCopy">
<icon class="sm" icon="file_copy"></icon>
<span :class="{ 'sr-only': hideText }" key="lang_copy_to_clipboard" v-translate>Copy to Clipboard</span>
</button>
</template>
<script>
import Clipboard from 'clipboard/dist/clipboard.min.js';
import '~/vendor/clipboard.js';
import Icon from './Icon';
export default {
components: { Icon },
props: {
target: {
text: {
type: String,
required: true
required: true,
},
hideText: {
type: Boolean,
default: false
}
},
data () {
return {
clipboard: null
};
},
mounted () {
this.clipboard = new Clipboard(this.$refs.btn);
},
beforeDestroy () {
this.clipboard.destroy();
methods: {
doCopy() {
this.$copyText(this.text).then(function (e) {
}, function (e) {
console.error(e);
})
}
}
};
</script>

View File

@ -1,5 +1,5 @@
<template>
<b-modal :size="size" :id="id" ref="modal" :title="title" :busy="loading">
<b-modal :size="size" :id="id" ref="modal" :title="title" :busy="loading" @hidden="onHidden">
<template #default="slotProps">
<b-overlay variant="card" :show="loading">
<b-alert variant="danger" :show="error != null">{{ error }}</b-alert>
@ -36,7 +36,7 @@ import InvisibleSubmitButton from "~/components/Common/InvisibleSubmitButton";
export default {
components: {InvisibleSubmitButton},
emits: ['submit'],
emits: ['submit', 'hidden'],
props: {
title: {
type: String,
@ -75,6 +75,9 @@ export default {
doSubmit() {
this.$emit('submit');
},
onHidden() {
this.$emit('hidden');
},
close() {
this.hide();
},

View File

@ -0,0 +1,50 @@
<template>
<b-modal id="logs_modal" ref="modal" :title="langLogView" @hidden="clearContents">
<streaming-log-view ref="logView" :log-url="logUrl"></streaming-log-view>
<template #modal-footer>
<b-button variant="default" type="button" @click="close">
<translate key="lang_btn_close">Close</translate>
</b-button>
<b-button variant="primary" class="btn_copy" @click.prevent="doCopy" type="button">
<translate key="lang_btn_copy">Copy to Clipboard</translate>
</b-button>
</template>
</b-modal>
</template>
<script>
import '~/vendor/clipboard.js';
import StreamingLogView from "~/components/Common/StreamingLogView";
export default {
name: 'StreamingLogModal',
components: {StreamingLogView},
data() {
return {
logUrl: null,
};
},
computed: {
langLogView() {
return this.$gettext('Log View');
}
},
methods: {
show(logUrl) {
this.logUrl = logUrl;
this.$refs.modal.show();
},
doCopy() {
this.$copyText(this.$refs.logView.getContents());
},
close() {
this.$refs.modal.hide();
},
clearContents() {
this.logUrl = null;
this.log = null;
}
}
};
</script>

View File

@ -0,0 +1,75 @@
<template>
<b-overlay variant="card" :show="loading">
<textarea class="form-control log-viewer" id="log-view-contents" spellcheck="false"
readonly>{{ logs }}</textarea>
</b-overlay>
</template>
<script>
export default {
name: 'StreamingLogView',
props: {
logUrl: {
type: String,
required: true,
}
},
data() {
return {
loading: false,
logs: '',
currentLogPosition: null,
timeoutUpdateLog: null,
};
},
mounted() {
this.loading = true;
this.axios({
method: 'GET',
url: this.logUrl
}).then((resp) => {
if (resp.data.contents !== '') {
this.logs = resp.data.contents + "\n";
} else {
this.logs = '';
}
this.currentLogPosition = resp.data.position;
if (!resp.data.eof) {
this.timeoutUpdateLog = setTimeout(this.updateLogs, 2500);
}
}).finally(() => {
this.loading = false;
});
},
beforeDestroy() {
clearTimeout(this.timeoutUpdateLog);
},
methods: {
updateLogs() {
this.axios({
method: 'GET',
url: this.logUrl,
params: {
position: this.currentLogPosition
}
}).then((resp) => {
if (resp.data.contents !== '') {
this.logs = this.logs + resp.data.contents + "\n";
}
this.currentLogPosition = resp.data.position;
if (!resp.data.eof) {
this.timeoutUpdateLog = setTimeout(this.updateLogs, 2500);
}
});
},
getContents() {
return this.logs;
},
}
};
</script>

View File

@ -2,7 +2,9 @@
<b-form-group v-bind="$attrs" :label-class="labelClassWithRequired" :label-for="id" :state="fieldState">
<template #default>
<slot name="default" v-bind="{ id, field, state: fieldState }">
<b-form-input type="text" :id="id" v-model="field.$model"
<b-form-textarea v-if="inputType === 'textarea'" :id="id" v-model="field.$model"
:state="fieldState"></b-form-textarea>
<b-form-input v-else :type="inputType" :id="id" v-model="field.$model"
:state="fieldState"></b-form-input>
</slot>
@ -37,6 +39,10 @@ export default {
type: Object,
required: true
},
inputType: {
type: String,
default: 'text'
},
labelClass: {
type: String,
default: ''

View File

@ -1,6 +1,6 @@
<template>
<modal-form ref="modal" :loading="loading" :title="langTitle" :error="error" :disable-save-button="$v.form.$invalid"
@submit="doSubmit">
@submit="doSubmit" @hidden="clearContents">
<b-tabs content-class="mt-3">
<mount-form-basic-info :form="$v.form"

View File

@ -1,6 +1,6 @@
<template>
<modal-form ref="modal" :loading="loading" :title="langTitle" :error="error" :disable-save-button="$v.form.$invalid"
@submit="doSubmit">
@submit="doSubmit" @hidden="clearContents">
<b-tabs content-class="mt-3">
<form-basic-info :form="$v.form"></form-basic-info>

View File

@ -1,6 +1,6 @@
<template>
<modal-form ref="modal" :loading="loading" :title="langTitle" :error="error" :disable-save-button="$v.form.$invalid"
@submit="doSubmit">
@submit="doSubmit" @hidden="clearContents">
<b-tabs content-class="mt-3">
<episode-form-basic-info :form="$v.form"></episode-form-basic-info>
@ -122,7 +122,7 @@ export default {
'explicit': d.explicit
};
},
buildSubmitRequest () {
getSubmittableFormData() {
let modifiedForm = this.form;
if (modifiedForm.publish_date.length > 0 && modifiedForm.publish_time.length > 0) {
let publishDateTimeString = modifiedForm.publish_date + ' ' + modifiedForm.publish_time;
@ -131,16 +131,8 @@ export default {
modifiedForm.publish_at = publishDateTime.toSeconds();
}
return {
method: (this.isEditMode)
? 'PUT'
: 'POST',
url: (this.isEditMode)
? this.editUrl
: this.createUrl,
data: this.form
};
}
return modifiedForm;
},
}
};
</script>

View File

@ -19,17 +19,14 @@
</template>
</b-wrapped-form-group>
<b-wrapped-form-group class="col-md-12" id="form_edit_description" :field="form.description">
<b-wrapped-form-group class="col-md-12" id="form_edit_description" :field="form.description"
input-type="textarea">
<template #label>
<translate key="lang_form_edit_description">Description</translate>
</template>
<template #description>
<translate key="lang_form_edit_description_desc">The description of the episode. The typical maximum amount of text allowed for this is 4000 characters.</translate>
</template>
<template #default="props">
<b-form-textarea :id="props.id" v-model="props.field.$model"
:state="props.state"></b-form-textarea>
</template>
</b-wrapped-form-group>
<b-wrapped-form-group class="col-md-6" id="form_edit_publish_date" :field="form.publish_date">

View File

@ -1,6 +1,6 @@
<template>
<modal-form ref="modal" :loading="loading" :title="langTitle" :error="error" :disable-save-button="$v.form.$invalid"
@submit="doSubmit">
@submit="doSubmit" @hidden="clearContents">
<b-tabs content-class="mt-3">
<podcast-form-basic-info :form="$v.form"

View File

@ -18,17 +18,14 @@
</template>
</b-wrapped-form-group>
<b-wrapped-form-group class="col-md-12" id="form_edit_description" :field="form.description">
<b-wrapped-form-group class="col-md-12" id="form_edit_description" :field="form.description"
input-type="textarea">
<template #label>
<translate key="lang_form_edit_description">Description</translate>
</template>
<template #description>
<translate key="lang_form_edit_description_desc">The description of your podcast. The typical maximum amount of text allowed for this is 4000 characters.</translate>
</template>
<template #default="props">
<b-form-textarea :id="props.id" v-model="props.field.$model"
:state="props.state"></b-form-textarea>
</template>
</b-wrapped-form-group>
<b-wrapped-form-group class="col-md-12" id="form_edit_language" :field="form.language">

View File

@ -40,8 +40,9 @@
<h2 class="card-title" v-translate key="lang_embed_code">Embed Code</h2>
</div>
<b-card-body>
<textarea id="request_embed_url" class="full-width form-control text-preformatted" spellcheck="false" style="height: 100px;">{{ embedCode }}</textarea>
<copy-to-clipboard-button target="#request_embed_url"></copy-to-clipboard-button>
<textarea class="full-width form-control text-preformatted" spellcheck="false"
style="height: 100px;">{{ embedCode }}</textarea>
<copy-to-clipboard-button :text="embedCode"></copy-to-clipboard-button>
</b-card-body>
</b-card>
</b-col>

View File

@ -26,22 +26,22 @@
<tr>
<td key="lang_frontend_admin_pw" v-translate>Administrator Password</td>
<td>
<span id="frontend_admin_pw">{{ frontendAdminPassword }}</span>
<copy-to-clipboard-button target="#frontend_admin_pw" hide-text></copy-to-clipboard-button>
{{ frontendAdminPassword }}
<copy-to-clipboard-button :text="frontendAdminPassword" hide-text></copy-to-clipboard-button>
</td>
</tr>
<tr>
<td key="lang_frontend_source_pw" v-translate>Source Password</td>
<td>
<span id="frontend_source_pw">{{ frontendSourcePassword }}</span>
<copy-to-clipboard-button target="#frontend_source_pw" hide-text></copy-to-clipboard-button>
{{ frontendSourcePassword }}
<copy-to-clipboard-button :text="frontendSourcePassword" hide-text></copy-to-clipboard-button>
</td>
</tr>
<tr v-if="isIcecast">
<td key="lang_frontend_relay_pw" v-translate>Relay Password</td>
<td>
<span id="frontend_relay_pw">{{ frontendRelayPassword }}</span>
<copy-to-clipboard-button target="#frontend_relay_pw" hide-text></copy-to-clipboard-button>
{{ frontendRelayPassword }}
<copy-to-clipboard-button :text="frontendRelayPassword" hide-text></copy-to-clipboard-button>
</td>
</tr>
</tbody>

View File

@ -1,12 +1,12 @@
<template>
<b-modal id="logs_modal" ref="modal" :title="langLogView">
<textarea class="form-control log-viewer" id="log-view-contents" spellcheck="false" readonly>{{ logs }}</textarea>
<textarea class="form-control log-viewer" spellcheck="false" readonly>{{ logs }}</textarea>
<template #modal-footer>
<b-button variant="default" type="button" @click="close">
<translate key="lang_btn_close">Close</translate>
</b-button>
<b-button variant="primary" class="btn_copy" data-clipboard-target="#log-view-contents" type="button">
<b-button variant="primary" class="btn_copy" @click.prevent="doCopy" type="button">
<translate key="lang_btn_copy">Copy to Clipboard</translate>
</b-button>
</template>
@ -14,14 +14,13 @@
</template>
<script>
import Clipboard from 'clipboard/dist/clipboard.min.js';
import '~/vendor/clipboard.js';
export default {
name: 'QueueLogsModal',
data () {
return {
logs: 'Loading...',
clipboard: null
};
},
computed: {
@ -29,14 +28,8 @@ export default {
return this.$gettext('Log View');
}
},
mounted () {
this.clipboard = new Clipboard('.btn_copy');
},
beforeDestroy () {
this.clipboard.destroy();
},
methods: {
show (logs) {
show(logs) {
let logDisplay = [];
logs.forEach(function (log) {
logDisplay.push(log.formatted);
@ -45,7 +38,10 @@ export default {
this.logs = logDisplay.join('');
this.$refs.modal.show();
},
close () {
doCopy() {
this.$copyText(this.logs);
},
close() {
this.$refs.modal.hide();
}
}

View File

@ -1,6 +1,6 @@
<template>
<modal-form ref="modal" :loading="loading" :title="langTitle" :error="error" :disable-save-button="$v.form.$invalid"
@submit="doSubmit">
@submit="doSubmit" @hidden="clearContents">
<b-tabs content-class="mt-3">
<remote-form-basic-info :form="$v.form"></remote-form-basic-info>

View File

@ -1,6 +1,6 @@
<template>
<modal-form ref="modal" :loading="loading" :title="langTitle" :error="error" :disable-save-button="$v.form.$invalid"
@submit="doSubmit">
@submit="doSubmit" @hidden="clearContents">
<b-tabs content-class="mt-3">
<form-basic-info :form="$v.form"></form-basic-info>

View File

@ -0,0 +1,146 @@
<template>
<div>
<b-card no-body>
<b-card-header header-bg-variant="primary-dark">
<h2 class="card-title" key="lang_title" v-translate>Web Hooks</h2>
</b-card-header>
<info-card>
<translate key="lang_info_card">Web hooks let you connect to external web services and broadcast changes to your station to them.</translate>
</info-card>
<b-card-body body-class="card-padding-sm">
<b-button variant="outline-primary" @click.prevent="doCreate">
<icon icon="add"></icon>
<translate key="lang_add_webhook">Add Web Hook</translate>
</b-button>
</b-card-body>
<data-table ref="datatable" id="station_webhooks" :show-toolbar="false" :fields="fields"
:api-url="listUrl">
<template #cell(name)="row">
<big>{{ row.item.name }}</big><br>
{{ getWebhookName(row.item.type) }}
<b-badge v-if="!row.item.is_enabled" variant="danger">
<translate key="lang_webhook_disabled">Disabled</translate>
</b-badge>
</template>
<template #cell(triggers)="row">
<div v-for="(name, index) in getTriggerNames(row.item.triggers)" :key="row.item.id+'_'+index"
class="small">
{{ name }}
</div>
</template>
<template #cell(actions)="row">
<b-button-group size="sm">
<b-button size="sm" variant="primary" @click.prevent="doEdit(row.item.links.self)">
<translate key="lang_btn_edit">Edit</translate>
</b-button>
<b-button size="sm" :variant="getToggleVariant(row.item)"
@click.prevent="doToggle(row.item.links.toggle)">
{{ langToggleButton(row.item) }}
</b-button>
<b-button size="sm" variant="default" @click.prevent="doTest(row.item.links.test)">
<translate key="lang_btn_test">Test</translate>
</b-button>
<b-button size="sm" variant="danger" @click.prevent="doDelete(row.item.links.self)">
<translate key="lang_btn_delete">Delete</translate>
</b-button>
</b-button-group>
</template>
</data-table>
</b-card>
<streaming-log-modal ref="logModal"></streaming-log-modal>
<edit-modal ref="editModal" :create-url="listUrl" :webhook-types="webhookTypes"
:webhook-triggers="webhookTriggers" @relist="relist"></edit-modal>
</div>
</template>
<script>
import DataTable from '~/components/Common/DataTable';
import EditModal from './Webhooks/EditModal';
import Icon from '~/components/Common/Icon';
import confirmDelete from "~/functions/confirmDelete";
import InfoCard from "~/components/Common/InfoCard";
import _ from 'lodash';
import StreamingLogModal from "~/components/Common/StreamingLogModal";
export default {
name: 'StationWebhooks',
components: {StreamingLogModal, InfoCard, Icon, EditModal, DataTable},
props: {
listUrl: String,
webhookTypes: Object,
webhookTriggers: Object
},
data() {
return {
fields: [
{key: 'name', isRowHeader: true, label: this.$gettext('Name/Type'), sortable: false},
{key: 'triggers', label: this.$gettext('Triggers'), sortable: false},
{key: 'actions', label: this.$gettext('Actions'), sortable: false, class: 'shrink'}
]
};
},
methods: {
langToggleButton(record) {
return (record.is_enabled)
? this.$gettext('Disable')
: this.$gettext('Enable');
},
getToggleVariant(record) {
return (record.is_enabled)
? 'warning'
: 'success';
},
getWebhookName(key) {
return _.get(this.webhookTypes, [key, 'name'], '');
},
getTriggerNames(triggers) {
return _.map(triggers, (trigger) => {
return _.get(this.webhookTriggers, trigger, '');
});
},
relist() {
this.$refs.datatable.refresh();
},
doCreate() {
this.$refs.editModal.create();
},
doEdit(url) {
this.$refs.editModal.edit(url);
},
doToggle(url) {
this.$wrapWithLoading(
this.axios.put(url)
).then((resp) => {
this.$notifySuccess(resp.data.message);
this.relist();
});
},
doTest(url) {
this.$wrapWithLoading(
this.axios.put(url)
).then((resp) => {
this.$refs.logModal.show(resp.data.links.log);
});
},
doDelete(url) {
confirmDelete({
title: this.$gettext('Delete Web Hook?'),
confirmButtonText: this.$gettext('Delete'),
}).then((result) => {
if (result.value) {
this.$wrapWithLoading(
this.axios.delete(url)
).then((resp) => {
this.$notifySuccess(resp.data.message);
this.relist();
});
}
});
}
}
};
</script>

View File

@ -0,0 +1,263 @@
<template>
<modal-form ref="modal" :loading="loading" :title="langTitle" :error="error" :disable-save-button="$v.form.$invalid"
@submit="doSubmit" @hidden="clearContents">
<type-select v-if="!type" :webhook-types="webhookTypes" @select="setType"></type-select>
<b-tabs v-else lazy content-class="mt-3">
<basic-info :trigger-options="triggerOptions" :form="$v.form"></basic-info>
<component :is="formComponent" :title="typeTitle" :form="$v.form"></component>
</b-tabs>
</modal-form>
</template>
<script>
import {required} from 'vuelidate/dist/validators.min.js';
import BaseEditModal from '~/components/Common/BaseEditModal';
import TypeSelect from "./Form/TypeSelect";
import BasicInfo from "./Form/BasicInfo";
import _ from "lodash";
import Generic from "~/components/Stations/Webhooks/Form/Generic";
import Email from "~/components/Stations/Webhooks/Form/Email";
import Tunein from "~/components/Stations/Webhooks/Form/Tunein";
import Discord from "~/components/Stations/Webhooks/Form/Discord";
import Telegram from "~/components/Stations/Webhooks/Form/Telegram";
import Twitter from "~/components/Stations/Webhooks/Form/Twitter";
import GoogleAnalytics from "~/components/Stations/Webhooks/Form/GoogleAnalytics";
import MatomoAnalytics from "~/components/Stations/Webhooks/Form/MatomoAnalytics";
export default {
name: 'EditModal',
components: {BasicInfo, TypeSelect},
mixins: [BaseEditModal],
props: {
webhookTypes: Object,
webhookTriggers: Object
},
data() {
return {
type: null,
webhookConfig: {
'generic': {
component: Generic,
validations: {
webhook_url: {required},
basic_auth_username: {},
basic_auth_password: {}
},
defaultConfig: {
webhook_url: '',
basic_auth_username: '',
basic_auth_password: ''
}
},
'email': {
component: Email,
validations: {
to: {required},
subject: {required},
message: {required}
},
defaultConfig: {
to: '',
subject: '',
message: ''
}
},
'tunein': {
component: Tunein,
validations: {
station_id: {required},
partner_id: {required},
partner_key: {required},
},
defaultConfig: {
station_id: '',
partner_id: '',
partner_key: ''
}
},
'discord': {
component: Discord,
validations: {
webhook_url: {required},
content: {},
title: {},
description: {},
url: {},
author: {},
thumbnail: {},
footer: {},
},
defaultConfig: {
webhook_url: '',
content: this.langDiscordDefaultContent,
title: '{{ now_playing.song.title }}',
description: '{{ now_playing.song.artist }}',
url: '{{ station.listen_url }}',
author: '{{ live.streamer_name }}',
thumbnail: '{{ now_playing.song.art }}',
footer: this.langPoweredByAzuraCast,
}
},
'telegram': {
component: Telegram,
validations: {
bot_token: {required},
chat_id: {required},
api: {},
text: {required},
parse_mode: {required}
},
defaultConfig: {
bot_token: '',
chat_id: '',
api: '',
text: '',
parse_mode: 'Markdown'
}
},
'twitter': {
component: Twitter,
validations: {
consumer_key: {required},
consumer_secret: {required},
token: {required},
token_secret: {required},
rate_limit: {},
message: {required}
},
defaultConfig: {
consumer_key: '',
consumer_secret: '',
token: '',
token_secret: '',
rate_limit: 0,
message: this.langTwitterDefaultMessage
}
},
'google_analytics': {
component: GoogleAnalytics,
validations: {
tracking_id: {required}
},
defaultConfig: {
tracking_id: ''
}
},
'matomo_analytics': {
component: MatomoAnalytics,
validations: {
matomo_url: {required},
site_id: {required},
token: {},
},
defaultConfig: {
matomo_url: '',
site_id: '',
token: ''
}
}
}
}
},
validations() {
let validations = {
type: {required},
form: {
name: {required},
triggers: {},
config: {}
}
};
if (this.triggerOptions.length > 0) {
validations.form.triggers = {required};
}
if (this.type !== null) {
validations.form.config = _.get(this.webhookConfig, [this.type, 'validations'], {});
}
return validations;
},
computed: {
langTitle() {
return this.isEditMode
? this.$gettext('Edit Web Hook')
: this.$gettext('Add Web Hook');
},
triggerOptions () {
if (!this.type) {
return [];
}
let webhookKeys = _.get(this.webhookTypes, [this.type, 'triggers'], []);
return _.map(webhookKeys, (key) => {
return {
text: this.webhookTriggers[key],
value: key
};
});
},
typeTitle() {
return _.get(this.webhookTypes, [this.type, 'name'], '');
},
formComponent() {
return _.get(this.webhookConfig, [this.type, 'component'], Generic);
},
langPoweredByAzuraCast() {
return this.$gettext('Powered by AzuraCast');
},
langDiscordDefaultContent() {
let msg = this.$gettext('Now playing on %{ station }:');
return this.$gettextInterpolate(msg, {'station': '{{ station.name }}'});
},
langTelegramDefaultContent() {
let msg = this.$gettext('Now playing on %{ station }: %{ title } by %{ artist }! Tune in now.');
return this.$gettextInterpolate(msg, {
station: '{{ station.name }}',
title: '{{ now_playing.song.title }}',
artist: '{{ now_playing.song.artist }}'
});
},
langTwitterDefaultMessage() {
let msg = this.$gettext('Now playing on %{ station }: %{ title } by %{ artist }! Tune in now: %{ url }');
return this.$gettextInterpolate(msg, {
station: '{{ station.name }}',
title: '{{ now_playing.song.title }}',
artist: '{{ now_playing.song.artist }}',
url: '{{ station.public_player_url }}'
});
},
},
methods: {
resetForm() {
this.type = null;
this.form = {
name: null,
triggers: [],
config: {}
};
},
setType(type) {
this.type = type;
this.form.config = _.get(this.webhookConfig, [type, 'defaultConfig'], {});
},
getSubmittableFormData() {
let formData = this.form;
if (!this.isEditMode) {
formData.type = this.type;
}
return formData;
},
populateForm(d) {
this.type = d.type;
this.form = {
name: d.name,
triggers: d.triggers,
config: d.config
};
}
}
};
</script>

View File

@ -0,0 +1,50 @@
<template>
<b-tab :title="langTabTitle" active>
<b-form-group>
<b-row>
<b-wrapped-form-group class="col-md-12" id="form_edit_name" :field="form.name">
<template #label>
<translate key="lang_form_edit_name">Web Hook Name</translate>
</template>
<template #description>
<translate key="lang_form_edit_name_desc">Choose a name for this webhook that will help you distinguish it from others. This will only be shown on the administration page.</translate>
</template>
</b-wrapped-form-group>
<b-wrapped-form-group v-if="triggerOptions.length > 0" class="col-md-12"
id="edit_form_triggers"
:field="form.triggers">
<template #label>
<translate key="lang_form_triggers">Web Hook Triggers</translate>
</template>
<template #description>
<translate key="lang_form_triggers_desc">This web hook will only run when the selected event(s) occur on this specific station.</translate>
</template>
<template #default="props">
<b-form-checkbox-group :id="props.id" :options="triggerOptions"
v-model="props.field.$model" stacked>
</b-form-checkbox-group>
</template>
</b-wrapped-form-group>
</b-row>
</b-form-group>
</b-tab>
</template>
<script>
import BWrappedFormGroup from "~/components/Form/BWrappedFormGroup";
export default {
name: 'BasicInfo',
components: {BWrappedFormGroup},
props: {
form: Object,
triggerOptions: Array
},
computed: {
langTabTitle() {
return this.$gettext('Basic Info');
}
}
}
</script>

View File

@ -0,0 +1,20 @@
<template>
<b-form-group>
<template #label>
<translate key="lang_customize_message_hdr">Message Customization Tips</translate>
</template>
<p class="card-text">
<translate key="lang_customize_message_desc_1">Variables are in the form of: </translate>
<code v-pre>{{ var.name }}</code>
</p>
<p class="card-text">
<translate key="lang_customize_message_desc_2">All values in the NowPlaying API response are available for use. Any empty fields are ignored.</translate>
<br>
<a href="https://azuracast.com/api" target="_blank">
<translate key="lang_customize_response_link">NowPlaying API Response</translate>
</a>
</p>
</b-form-group>
</template>

View File

@ -0,0 +1,81 @@
<template>
<b-tab :title="title">
<b-form-group>
<b-row>
<b-wrapped-form-group class="col-md-12" id="form_config_webhook_url" :field="form.config.webhook_url"
input-type="url">
<template #label>
<translate key="lang_form_webhook_url">Discord Web Hook URL</translate>
</template>
<template #description>
<translate
key="lang_form_webhook_url">This URL is provided within the Discord application.</translate>
</template>
</b-wrapped-form-group>
</b-row>
</b-form-group>
<common-formatting-info></common-formatting-info>
<b-form-group>
<b-row>
<b-wrapped-form-group class="col-md-6" id="form_config_content" :field="form.config.content">
<template #label>
<translate key="lang_form_config_content">Main Message Content</translate>
</template>
</b-wrapped-form-group>
<b-wrapped-form-group class="col-md-6" id="form_config_title" :field="form.config.title">
<template #label>
<translate key="lang_form_config_title">Title</translate>
</template>
</b-wrapped-form-group>
<b-wrapped-form-group class="col-md-6" id="form_config_description" :field="form.config.description">
<template #label>
<translate key="lang_form_config_description">Description</translate>
</template>
</b-wrapped-form-group>
<b-wrapped-form-group class="col-md-6" id="form_config_url" :field="form.config.url" input-type="url">
<template #label>
<translate key="lang_form_config_url">URL</translate>
</template>
</b-wrapped-form-group>
<b-wrapped-form-group class="col-md-6" id="form_config_author" :field="form.config.author">
<template #label>
<translate key="lang_form_config_author">Author Name</translate>
</template>
</b-wrapped-form-group>
<b-wrapped-form-group class="col-md-6" id="form_config_thumbnail" :field="form.config.thumbnail"
input-type="url">
<template #label>
<translate key="lang_form_config_thumbnail">Thumbnail Image URL</translate>
</template>
</b-wrapped-form-group>
<b-wrapped-form-group class="col-md-6" id="form_config_footer" :field="form.config.footer">
<template #label>
<translate key="lang_form_config_footer">Footer Text</translate>
</template>
</b-wrapped-form-group>
</b-row>
</b-form-group>
</b-tab>
</template>
<script>
import BWrappedFormGroup from "~/components/Form/BWrappedFormGroup";
import CommonFormattingInfo from "./CommonFormattingInfo";
export default {
name: 'Discord',
components: {CommonFormattingInfo, BWrappedFormGroup},
props: {
title: String,
form: Object
}
}
</script>

View File

@ -0,0 +1,48 @@
<template>
<b-tab :title="title">
<b-form-group>
<b-row>
<b-wrapped-form-group class="col-md-12" id="form_config_to" :field="form.config.to">
<template #label>
<translate key="lang_form_to">Message Recipient(s)</translate>
</template>
<template #description>
<translate key="lang_form_to_desc">E-mail addresses can be separated by commas.</translate>
</template>
</b-wrapped-form-group>
</b-row>
</b-form-group>
<common-formatting-info></common-formatting-info>
<b-form-group>
<b-row>
<b-wrapped-form-group class="col-md-12" id="form_config_subject" :field="form.config.subject">
<template #label>
<translate key="lang_form_config_subject">Message Subject</translate>
</template>
</b-wrapped-form-group>
<b-wrapped-form-group class="col-md-12" id="form_config_message" :field="form.config.message">
<template #label>
<translate key="lang_form_config_message">Message Body</translate>
</template>
</b-wrapped-form-group>
</b-row>
</b-form-group>
</b-tab>
</template>
<script>
import BWrappedFormGroup from "~/components/Form/BWrappedFormGroup";
import CommonFormattingInfo from "./CommonFormattingInfo";
export default {
name: 'Email',
components: {CommonFormattingInfo, BWrappedFormGroup},
props: {
title: String,
form: Object
}
}
</script>

View File

@ -0,0 +1,75 @@
<template>
<b-tab :title="title">
<b-form-group>
<template #label>
<translate key="lang_customize_message_hdr">Web Hook Details</translate>
</template>
<p class="card-text">
<translate key="lang_customize_message_desc_1">Web hooks automatically send a HTTP POST request to the URL you specify to notify it any time one of the triggers you specify occurs on your station.</translate>
</p>
<p class="card-text">
<translate key="lang_customize_message_desc_2">The body of the POST message is the exact same as the NowPlaying API response for your station.</translate>
</p>
<ul>
<li>
<a href="https://azuracast.com/api" target="_blank">
<translate key="lang_customize_response_link">NowPlaying API Response</translate>
</a>
</li>
</ul>
<p class="card-text">
<translate key="lang_customize_message_desc_3">In order to process quickly, web hooks have a short timeout, so the responding service should be optimized to handle the request in under 2 seconds.</translate>
</p>
</b-form-group>
<b-form-group>
<b-row>
<b-wrapped-form-group class="col-md-12" id="form_config_webhook_url" :field="form.config.webhook_url"
input-type="url">
<template #label>
<translate key="lang_form_config_webhook_url">Web Hook URL</translate>
</template>
<template #description>
<translate key="lang_form_config_webhook_url_desc">The URL that will receive the POST messages any time an event is triggered.</translate>
</template>
</b-wrapped-form-group>
<b-wrapped-form-group class="col-md-6" id="form_config_basic_auth_username"
:field="form.config.basic_auth_username">
<template #label>
<translate
key="lang_form_config_basic_auth_username">Optional: HTTP Basic Authentication Username</translate>
</template>
<template #description>
<translate key="lang_form_config_basic_auth_username_desc">If your web hook requires HTTP basic authentication, provide the username here.</translate>
</template>
</b-wrapped-form-group>
<b-wrapped-form-group class="col-md-6" id="form_config_basic_auth_password"
:field="form.config.basic_auth_password">
<template #label>
<translate
key="lang_form_config_basic_auth_password">Optional: HTTP Basic Authentication Password</translate>
</template>
<template #description>
<translate key="lang_form_config_basic_auth_password_desc">If your web hook requires HTTP basic authentication, provide the password here.</translate>
</template>
</b-wrapped-form-group>
</b-row>
</b-form-group>
</b-tab>
</template>
<script>
import BWrappedFormGroup from "~/components/Form/BWrappedFormGroup";
export default {
name: 'Generic',
components: {BWrappedFormGroup},
props: {
title: String,
form: Object
}
}
</script>

View File

@ -0,0 +1,30 @@
<template>
<b-tab :title="title">
<b-form-group>
<b-row>
<b-wrapped-form-group class="col-md-12" id="form_config_tracking_id" :field="form.config.tracking_id">
<template #label>
<translate key="lang_form_config_tracking_id">GA Property Tracking ID</translate>
</template>
<template #description>
<translate
key="lang_form_config_tracking_id_desc">The property ID used to track live listeners.</translate>
</template>
</b-wrapped-form-group>
</b-row>
</b-form-group>
</b-tab>
</template>
<script>
import BWrappedFormGroup from "~/components/Form/BWrappedFormGroup";
export default {
name: 'GoogleAnalytics',
components: {BWrappedFormGroup},
props: {
title: String,
form: Object
}
}
</script>

View File

@ -0,0 +1,51 @@
<template>
<b-tab :title="title">
<b-form-group>
<b-row>
<b-wrapped-form-group class="col-md-12" id="form_config_matomo_url" :field="form.config.matomo_url"
input-type="url">
<template #label>
<translate key="lang_form_config_matomo_url">Matomo Installation Base URL</translate>
</template>
<template #description>
<translate
key="lang_form_config_matomo_url_desc">The full base URL of your Matomo installation.</translate>
</template>
</b-wrapped-form-group>
<b-wrapped-form-group class="col-md-6" id="form_config_site_id" :field="form.config.site_id">
<template #label>
<translate key="lang_form_config_site_id">Matomo Site ID</translate>
</template>
<template #description>
<translate
key="lang_form_config_site_id_desc">The numeric site ID for this site.</translate>
</template>
</b-wrapped-form-group>
<b-wrapped-form-group class="col-md-6" id="form_config_token" :field="form.config.token">
<template #label>
<translate key="lang_form_config_token">Matomo API Token</translate>
</template>
<template #description>
<translate
key="lang_form_config_site_id_desc">Optionally supply an API token to allow IP address overriding.</translate>
</template>
</b-wrapped-form-group>
</b-row>
</b-form-group>
</b-tab>
</template>
<script>
import BWrappedFormGroup from "~/components/Form/BWrappedFormGroup";
export default {
name: 'MatomoAnalytics',
components: {BWrappedFormGroup},
props: {
title: String,
form: Object
}
}
</script>

View File

@ -0,0 +1,93 @@
<template>
<b-tab :title="title">
<b-form-group>
<b-row>
<b-wrapped-form-group class="col-md-6" id="form_config_bot_token" :field="form.config.bot_token">
<template #label>
<translate key="lang_form_config_bot_token">Bot Token</translate>
</template>
<template #description>
<a href="https://core.telegram.org/bots#botfather" target="_blank">
<translate key="lang_form_config_bot_token_desc">See the Telegram Documentation for more details.</translate>
</a>
</template>
</b-wrapped-form-group>
<b-wrapped-form-group class="col-md-6" id="form_config_chat_id" :field="form.config.chat_id">
<template #label>
<translate key="lang_form_config_chat_id">Chat ID</translate>
</template>
<template #description>
<translate key="lang_form_config_chat_id_desc">Unique identifier for the target chat or username of the target channel (in the format @channelusername).</translate>
</template>
</b-wrapped-form-group>
<b-wrapped-form-group class="col-md-6" id="form_config_api" :field="form.config.api">
<template #label>
<translate key="lang_form_config_api">Custom API Base URL</translate>
</template>
<template #description>
<translate key="lang_form_config_api_desc">Leave blank to use the default Telegram API URL (recommended).</translate>
</template>
</b-wrapped-form-group>
</b-row>
</b-form-group>
<common-formatting-info></common-formatting-info>
<b-form-group>
<b-row>
<b-wrapped-form-group class="col-md-12" id="form_config_text" :field="form.config.text"
input-type="textarea">
<template #label>
<translate key="lang_form_config_text">Main Message Content</translate>
</template>
</b-wrapped-form-group>
<b-wrapped-form-group class="col-md-12" id="form_config_parse_mode" :field="form.config.parse_mode">
<template #label>
<translate key="lang_form_config_parse_mode">Message parsing mode</translate>
</template>
<template #description>
<a href="https://core.telegram.org/bots/api#sendmessage" target="_blank">
<translate key="lang_form_config_parse_mode_desc">See the Telegram documentation for more details.</translate>
</a>
</template>
<template #default="props">
<b-form-radio-group stacked :id="props.id" :options="parseModeOptions"
v-model="props.field.$model">
</b-form-radio-group>
</template>
</b-wrapped-form-group>
</b-row>
</b-form-group>
</b-tab>
</template>
<script>
import BWrappedFormGroup from "~/components/Form/BWrappedFormGroup";
import CommonFormattingInfo from "./CommonFormattingInfo";
export default {
name: 'Telegram',
components: {CommonFormattingInfo, BWrappedFormGroup},
props: {
title: String,
form: Object
},
computed: {
parseModeOptions() {
return [
{
text: this.$gettext('Markdown'),
value: 'Markdown',
},
{
text: this.$gettext('HTML'),
value: 'HTML',
}
];
}
}
}
</script>

View File

@ -0,0 +1,41 @@
<template>
<b-tab :title="title">
<b-form-group>
<b-row>
<b-wrapped-form-group class="col-md-6" id="form_config_station_id" :field="form.config.station_id">
<template #label>
<translate key="lang_form_station_id">TuneIn Station ID</translate>
</template>
<template #description>
<translate key="lang_form_station_id_desc">The station ID will be a numeric string that starts with the letter S.</translate>
</template>
</b-wrapped-form-group>
<b-wrapped-form-group class="col-md-6" id="form_config_partner_id" :field="form.config.partner_id">
<template #label>
<translate key="lang_form_partner_id">TuneIn Partner ID</translate>
</template>
</b-wrapped-form-group>
<b-wrapped-form-group class="col-md-6" id="form_config_partner_key" :field="form.config.partner_key">
<template #label>
<translate key="lang_form_partner_key">TuneIn Partner Key</translate>
</template>
</b-wrapped-form-group>
</b-row>
</b-form-group>
</b-tab>
</template>
<script>
import BWrappedFormGroup from "~/components/Form/BWrappedFormGroup";
export default {
name: 'Tunein',
components: {BWrappedFormGroup},
props: {
title: String,
form: Object
}
}
</script>

View File

@ -0,0 +1,150 @@
<template>
<b-tab :title="title">
<b-form-group>
<template #label>
<translate key="lang_twitter_instructions_hdr">Twitter Account Details</translate>
</template>
<p class="card-text">
<translate key="lang_twitter_instructions_1">Steps for configuring a Twitter application:</translate>
</p>
<ul>
<li>
<translate key="lang_twitter_instructions_1">Create a new app on the Twitter Applications site. Use this installation's base URL as the application URL.</translate>
<br>
<a href="https://developer.twitter.com/en/apps" target="_blank">
<translate key="lang_twitter_instructions_url">Twitter Applications</translate>
</a>
</li>
<li>
<translate key="lang_twitter_instructions_2">In the newly created application, click the "Keys and Access Tokens" tab.</translate>
</li>
<li>
<translate key="lang_twitter_instructions_3">At the bottom of the page, click "Create my access token".</translate>
</li>
</ul>
<p class="card-text">
<translate key="lang_twitter_instructions_4">Once these steps are completed, enter the information from the "Keys and Access Tokens" page into the fields below.</translate>
</p>
</b-form-group>
<b-form-group>
<b-row>
<b-wrapped-form-group class="col-md-6" id="form_config_consumer_key" :field="form.config.consumer_key">
<template #label>
<translate key="lang_form_config_consumer_key">Consumer Key (API Key)</translate>
</template>
</b-wrapped-form-group>
<b-wrapped-form-group class="col-md-6" id="form_config_consumer_secret"
:field="form.config.consumer_secret">
<template #label>
<translate key="lang_form_config_consumer_secret">Consumer Secret (API Secret)</translate>
</template>
</b-wrapped-form-group>
<b-wrapped-form-group class="col-md-6" id="form_config_token" :field="form.config.token">
<template #label>
<translate key="lang_form_config_token">Access Token</translate>
</template>
</b-wrapped-form-group>
<b-wrapped-form-group class="col-md-6" id="form_config_token_secret" :field="form.config.token_secret">
<template #label>
<translate key="lang_form_config_token_secret">Access Token Secret</translate>
</template>
</b-wrapped-form-group>
<b-wrapped-form-group class="col-md-12" id="form_config_rate_limit" :field="form.config.rate_limit">
<template #label>
<translate key="lang_form_config_rate_limit">Only Send One Tweet Every...</translate>
</template>
<template #default="props">
<b-form-radio-group stacked :id="props.id" :options="rateLimitOptions"
v-model="props.field.$model">
</b-form-radio-group>
</template>
</b-wrapped-form-group>
</b-row>
</b-form-group>
<common-formatting-info></common-formatting-info>
<b-form-group>
<b-row>
<b-wrapped-form-group class="col-md-12" id="form_config_message" :field="form.config.message"
input-type="textarea">
<template #label>
<translate key="lang_form_config_message">Message Body</translate>
</template>
</b-wrapped-form-group>
</b-row>
</b-form-group>
</b-tab>
</template>
<script>
import BWrappedFormGroup from "~/components/Form/BWrappedFormGroup";
import CommonFormattingInfo from "./CommonFormattingInfo";
export default {
name: 'Twitter',
components: {CommonFormattingInfo, BWrappedFormGroup},
props: {
title: String,
form: Object
},
computed: {
langSeconds() {
return this.$gettext('%{ seconds } seconds');
},
langMinutes() {
return this.$gettext('%{ minutes } minutes');
},
rateLimitOptions() {
return [
{
text: this.$gettext('No Limit'),
value: 0,
},
{
text: this.$gettextInterpolate(this.langSeconds, {seconds: 15}),
value: 15,
},
{
text: this.$gettextInterpolate(this.langSeconds, {seconds: 30}),
value: 30,
},
{
text: this.$gettextInterpolate(this.langSeconds, {seconds: 60}),
value: 60,
},
{
text: this.$gettextInterpolate(this.langMinutes, {minutes: 2}),
value: 120,
},
{
text: this.$gettextInterpolate(this.langMinutes, {minutes: 5}),
value: 300,
},
{
text: this.$gettextInterpolate(this.langMinutes, {minutes: 10}),
value: 600,
},
{
text: this.$gettextInterpolate(this.langMinutes, {minutes: 15}),
value: 900,
},
{
text: this.$gettextInterpolate(this.langMinutes, {minutes: 30}),
value: 1800,
},
{
text: this.$gettextInterpolate(this.langMinutes, {minutes: 60}),
value: 3600,
}
];
}
}
}
</script>

View File

@ -0,0 +1,30 @@
<template>
<b-form-group>
<template #label>
<translate key="lang_select_type_header">Select Web Hook Type</translate>
</template>
<b-list-group>
<b-list-group-item v-for="(info, key) in webhookTypes" :key="key" href="#" @click.prevent="selectType(key)"
class="px-3">
<h6 class="font-weight-bold mb-0">{{ info.name }}</h6>
<p class="card-text small">{{ info.description }}</p>
</b-list-group-item>
</b-list-group>
</b-form-group>
</template>
<script>
export default {
name: 'TypeSelect',
emits: ['select'],
props: {
webhookTypes: Object,
},
methods: {
selectType(type) {
this.$emit('select', type);
}
}
}
</script>

View File

@ -0,0 +1,9 @@
import initBase
from '~/base.js';
import '~/vendor/bootstrapVue.js';
import Webhooks
from '~/components/Stations/Webhooks';
export default initBase(Webhooks);

8
frontend/vue/vendor/clipboard.js vendored Normal file
View File

@ -0,0 +1,8 @@
import Vue
from 'vue';
import VueClipboard
from 'vue-clipboard2';
VueClipboard.config.autoSetContainer = true;
Vue.use(VueClipboard);

View File

@ -31,7 +31,8 @@ module.exports = {
StationsReportsRequests: '~/pages/Stations/Reports/Requests.js',
StationsReportsOverview: '~/pages/Stations/Reports/Overview.js',
StationsReportsPerformance: '~/pages/Stations/Reports/Performance.js',
StationsReportsTimeline: '~/pages/Stations/Reports/Timeline.js'
StationsReportsTimeline: '~/pages/Stations/Reports/Timeline.js',
StationsWebhooks: '~/pages/Stations/Webhooks.js'
},
resolve: {
enforceExtension: false,

View File

@ -4,97 +4,13 @@ declare(strict_types=1);
namespace App\Controller;
use App\Controller\Api\Traits\HasLogViewer;
use App\Entity;
use App\Exception\NotFoundException;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Radio\Adapters;
use Psr\Http\Message\ResponseInterface;
abstract class AbstractLogViewerController
{
public static int $maximum_log_size = 1048576;
protected function view(
ServerRequest $request,
Response $response,
string $log_path,
bool $tail_file = true
): ResponseInterface {
clearstatcache();
if (!is_file($log_path)) {
throw new NotFoundException('Log file not found!');
}
if (!$tail_file) {
$log = file_get_contents($log_path) ?: '';
$log_contents = $this->processLog($request, $log);
return $response->withJson([
'contents' => $log_contents,
'eof' => true,
]);
}
$params = $request->getQueryParams();
$last_viewed_size = (int)($params['position'] ?? 0);
$log_size = filesize($log_path);
if ($last_viewed_size > $log_size) {
$last_viewed_size = $log_size;
}
$log_visible_size = ($log_size - $last_viewed_size);
$cut_first_line = false;
if ($log_visible_size > self::$maximum_log_size) {
$log_visible_size = self::$maximum_log_size;
$cut_first_line = true;
}
$log_contents = '';
if ($log_visible_size > 0) {
$fp = fopen($log_path, 'rb');
if (false === $fp) {
throw new \RuntimeException(sprintf('Could not open file at path "%s".', $log_path));
}
fseek($fp, -$log_visible_size, SEEK_END);
$log_contents_raw = fread($fp, $log_visible_size) ?: '';
fclose($fp);
$log_contents = $this->processLog($request, $log_contents_raw, $cut_first_line, true);
}
return $response->withJson([
'contents' => $log_contents,
'position' => $log_size,
'eof' => false,
]);
}
protected function processLog(
ServerRequest $request,
string $rawLog,
bool $cutFirstLine = false,
bool $cutEmptyLastLine = false
): string {
$logParts = explode("\n", $rawLog);
if ($cutFirstLine) {
array_shift($logParts);
}
if ($cutEmptyLastLine && end($logParts) === '') {
array_pop($logParts);
}
$logParts = str_replace(['>', '<'], ['&gt;', '&lt;'], $logParts);
$log = implode("\n", $logParts);
return mb_convert_encoding($log, 'UTF-8', 'UTF-8');
}
use HasLogViewer;
/**
* @return array<string, array>

View File

@ -171,7 +171,7 @@ class BackupsController extends AbstractLogViewerController
): ResponseInterface {
$logPath = File::validateTempPath($path);
return $this->view($request, $response, $logPath, true);
return $this->streamLogToResponse($request, $response, $logPath, true);
}
public function downloadAction(

View File

@ -90,7 +90,7 @@ class DebugController extends AbstractLogViewerController
): ResponseInterface {
$logPath = File::validateTempPath($path);
return $this->view($request, $response, $logPath, true);
return $this->streamLogToResponse($request, $response, $logPath, true);
}
public function nextsongAction(

View File

@ -101,6 +101,6 @@ class LogsController extends AbstractLogViewerController
}
$logArea = $log_areas[$log];
return $this->view($request, $response, $logArea['path'], $logArea['tail'] ?? true);
return $this->streamLogToResponse($request, $response, $logArea['path'], $logArea['tail'] ?? true);
}
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Webhooks;
use App\Entity;
use App\Exception\NotFoundException;
use Doctrine\ORM\EntityManagerInterface;
abstract class AbstractWebhooksAction
{
public function __construct(
protected EntityManagerInterface $em
) {
}
protected function requireRecord(Entity\Station $station, int $id): Entity\StationWebhook
{
$record = $this->em->getRepository(Entity\StationWebhook::class)->findOneBy(
[
'station' => $station,
'id' => $id,
]
);
if (!$record instanceof Entity\StationWebhook) {
throw new NotFoundException(__('Web hook not found.'));
}
return $record;
}
}

View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Webhooks;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Message\TestWebhookMessage;
use App\Utilities\File;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\Messenger\MessageBus;
class TestAction extends AbstractWebhooksAction
{
public function __invoke(
ServerRequest $request,
Response $response,
MessageBus $messageBus,
int $id
): ResponseInterface {
$this->requireRecord($request->getStation(), $id);
$tempFile = File::generateTempPath('webhook_test_' . $id . '.log');
$message = new TestWebhookMessage();
$message->webhookId = $id;
$message->outputPath = $tempFile;
$messageBus->dispatch($message);
$router = $request->getRouter();
return $response->withJson(
[
'success' => true,
'links' => [
'log' => (string)$router->fromHere('api:stations:webhook:test-log', [
'path' => basename($tempFile),
]),
],
]
);
}
}

View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Webhooks;
use App\Controller\Api\Traits\HasLogViewer;
use App\Entity;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Utilities\File;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\Messenger\MessageBus;
class TestLogAction extends AbstractWebhooksAction
{
use HasLogViewer;
public function __invoke(
ServerRequest $request,
Response $response,
MessageBus $messageBus,
int $id,
string $path
): ResponseInterface {
$this->requireRecord($request->getStation(), $id);
$logPathPortion = 'webhook_test_' . $id;
if (!str_contains($path, $logPathPortion)) {
return $response
->withStatus(403)
->withJson(new Entity\Api\Error(403, 'Invalid log path.'));
}
$tempPath = File::validateTempPath($path);
return $this->streamLogToResponse(
$request,
$response,
$tempPath,
true
);
}
}

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Webhooks;
use App\Entity;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
class ToggleAction extends AbstractWebhooksAction
{
public function __invoke(ServerRequest $request, Response $response, int $id): ResponseInterface
{
$record = $this->requireRecord($request->getStation(), $id);
$newValue = $record->toggleEnabled();
$this->em->persist($record);
$this->em->flush();
$flash_message = ($newValue)
? __('Web hook enabled.')
: __('Web hook disabled.');
return $response->withJson(new Entity\Api\Status(true, $flash_message));
}
}

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Controller\Api\Stations;
use App\Entity;
use App\Http\ServerRequest;
use OpenApi\Annotations as OA;
/**
@ -98,4 +99,36 @@ class WebhooksController extends AbstractStationApiCrudController
* security={{"api_key": {}}},
* )
*/
protected function viewRecord(object $record, ServerRequest $request): mixed
{
if (!($record instanceof Entity\StationWebhook)) {
throw new \InvalidArgumentException(sprintf('Record must be an instance of %s.', $this->entityClass));
}
$return = $this->toArray($record);
$isInternal = ('true' === $request->getParam('internal', 'false'));
$router = $request->getRouter();
$return['links'] = [
'self' => (string)$router->fromHere(
route_name: $this->resourceRouteName,
route_params: ['id' => $record->getIdRequired()],
absolute: !$isInternal
),
'toggle' => (string)$router->fromHere(
route_name: 'api:stations:webhook:toggle',
route_params: ['id' => $record->getIdRequired()],
absolute: !$isInternal
),
'test' => (string)$router->fromHere(
route_name: 'api:stations:webhook:test',
route_params: ['id' => $record->getIdRequired()],
absolute: !$isInternal
),
];
return $return;
}
}

View File

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Traits;
use App\Exception\NotFoundException;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
trait HasLogViewer
{
public static int $maximum_log_size = 1048576;
protected function streamLogToResponse(
ServerRequest $request,
Response $response,
string $log_path,
bool $tail_file = true
): ResponseInterface {
clearstatcache();
if (!is_file($log_path)) {
throw new NotFoundException('Log file not found!');
}
if (!$tail_file) {
$log = file_get_contents($log_path) ?: '';
$log_contents = $this->processLog($request, $log);
return $response->withJson(
[
'contents' => $log_contents,
'eof' => true,
]
);
}
$params = $request->getQueryParams();
$last_viewed_size = (int)($params['position'] ?? 0);
$log_size = filesize($log_path);
if ($last_viewed_size > $log_size) {
$last_viewed_size = $log_size;
}
$log_visible_size = ($log_size - $last_viewed_size);
$cut_first_line = false;
if ($log_visible_size > self::$maximum_log_size) {
$log_visible_size = self::$maximum_log_size;
$cut_first_line = true;
}
$log_contents = '';
if ($log_visible_size > 0) {
$fp = fopen($log_path, 'rb');
if (false === $fp) {
throw new \RuntimeException(sprintf('Could not open file at path "%s".', $log_path));
}
fseek($fp, -$log_visible_size, SEEK_END);
$log_contents_raw = fread($fp, $log_visible_size) ?: '';
fclose($fp);
$log_contents = $this->processLog($request, $log_contents_raw, $cut_first_line, true);
}
return $response->withJson(
[
'contents' => $log_contents,
'position' => $log_size,
'eof' => false,
]
);
}
protected function processLog(
ServerRequest $request,
string $rawLog,
bool $cutFirstLine = false,
bool $cutEmptyLastLine = false
): string {
$logParts = explode("\n", $rawLog);
if ($cutFirstLine) {
array_shift($logParts);
}
if ($cutEmptyLastLine && end($logParts) === '') {
array_pop($logParts);
}
$logParts = str_replace(['>', '<'], ['&gt;', '&lt;'], $logParts);
$log = implode("\n", $logParts);
return mb_convert_encoding($log, 'UTF-8', 'UTF-8');
}
}

View File

@ -31,7 +31,7 @@ class LogsController extends AbstractLogViewerController
}
$logArea = $log_areas[$log];
return $this->view($request, $response, $logArea['path'], $logArea['tail'] ?? true);
return $this->streamLogToResponse($request, $response, $logArea['path'], $logArea['tail'] ?? true);
}
protected function processLog(

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Controller\Stations;
use App\Config;
use App\Entity\Repository\SettingsRepository;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
class WebhooksAction
{
public function __invoke(
ServerRequest $request,
Response $response,
SettingsRepository $settingsRepo,
Config $config
): ResponseInterface {
$router = $request->getRouter();
$settings = $settingsRepo->readSettings();
$webhookConfig = $config->get('webhooks');
return $request->getView()->renderToResponse(
$response,
'system/vue',
[
'title' => __('Web Hooks'),
'id' => 'station-webhooks',
'component' => 'Vue_StationsWebhooks',
'props' => [
'listUrl' => (string)$router->fromHere('api:stations:webhooks'),
'webhookTypes' => $webhookConfig['webhooks'],
'webhookTriggers' => $webhookConfig['triggers'],
'enableAdvancedFeatures' => $settings->getEnableAdvancedFeatures(),
],
]
);
}
}

View File

@ -1,149 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller\Stations;
use App\Entity;
use App\Form\StationWebhookForm;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Session\Flash;
use App\Webhook\Dispatcher;
use DI\FactoryInterface;
use Psr\Http\Message\ResponseInterface;
class WebhooksController extends AbstractStationCrudController
{
protected array $webhook_config;
public function __construct(
protected Dispatcher $dispatcher,
FactoryInterface $factory
) {
$form = $factory->make(StationWebhookForm::class);
parent::__construct($form);
$this->webhook_config = $form->getConfig();
$this->csrf_namespace = 'stations_webhooks';
}
public function indexAction(ServerRequest $request, Response $response): ResponseInterface
{
$station = $request->getStation();
return $request->getView()->renderToResponse($response, 'stations/webhooks/index', [
'webhooks' => $station->getWebhooks(),
'webhook_config' => $this->webhook_config,
'csrf' => $request->getCsrf()->generate($this->csrf_namespace),
]);
}
public function addAction(ServerRequest $request, Response $response, string $type = null): ResponseInterface
{
$view = $request->getView();
if ($type === null) {
return $view->renderToResponse(
$response,
'stations/webhooks/add',
[
'connectors' => array_filter(
$this->webhook_config['webhooks'],
static function ($webhook) {
return !empty($webhook['name']);
}
),
]
);
}
$record = new Entity\StationWebhook($request->getStation(), $type);
if (false !== $this->form->process($request, $record)) {
$request->getFlash()->addMessage('<b>' . __('Web Hook added.') . '</b>', Flash::SUCCESS);
return $response->withRedirect((string)$request->getRouter()->fromHere('stations:webhooks:index'));
}
return $view->renderToResponse($response, 'system/form_page', [
'form' => $this->form,
'render_mode' => 'edit',
'title' => __('Add Web Hook'),
]);
}
public function editAction(ServerRequest $request, Response $response, int $id): ResponseInterface
{
if (false !== $this->doEdit($request, $id)) {
$request->getFlash()->addMessage('<b>' . __('Web Hook updated.') . '</b>', Flash::SUCCESS);
return $response->withRedirect((string)$request->getRouter()->fromHere('stations:webhooks:index'));
}
return $request->getView()->renderToResponse(
$response,
'system/form_page',
[
'form' => $this->form,
'render_mode' => 'edit',
'title' => __('Edit Web Hook'),
]
);
}
public function toggleAction(
ServerRequest $request,
Response $response,
int $id,
string $csrf
): ResponseInterface {
$request->getCsrf()->verify($csrf, $this->csrf_namespace);
/** @var Entity\StationWebhook $record */
$record = $this->getRecord($request->getStation(), $id);
$new_status = $record->toggleEnabled();
$this->em->persist($record);
$this->em->flush();
$request->getFlash()->addMessage(
'<b>' . ($new_status ? __('Web hook enabled.') : __('Web Hook disabled.')) . '</b>',
Flash::SUCCESS
);
return $response->withRedirect((string)$request->getRouter()->fromHere('stations:webhooks:index'));
}
public function testAction(
ServerRequest $request,
Response $response,
int $id,
string $csrf
): ResponseInterface {
$request->getCsrf()->verify($csrf, $this->csrf_namespace);
$station = $request->getStation();
/** @var Entity\StationWebhook $record */
$record = $this->getRecord($station, $id);
$log_records = $this->dispatcher->testDispatch($station, $record)->getRecords();
return $request->getView()->renderToResponse($response, 'system/log_view', [
'title' => __('Web Hook Test Output'),
'log_records' => $log_records,
]);
}
public function deleteAction(
ServerRequest $request,
Response $response,
int $id,
string $csrf
): ResponseInterface {
$this->doDelete($request, $id, $csrf);
$request->getFlash()->addMessage('<b>' . __('Web Hook deleted.') . '</b>', Flash::SUCCESS);
return $response->withRedirect((string)$request->getRouter()->fromHere('stations:webhooks:index'));
}
}

View File

@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Config;
use App\Entity;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class StationRemoteForm extends EntityForm
{
public function __construct(
EntityManagerInterface $em,
Serializer $serializer,
ValidatorInterface $validator,
Config $config
) {
$form_config = $config->get('forms/remote');
parent::__construct($em, $serializer, $validator, $form_config);
$this->entityClass = Entity\StationRemote::class;
}
}

View File

@ -1,85 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Config;
use App\Entity;
use App\Environment;
use App\Http\Router;
use App\Http\ServerRequest;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class StationWebhookForm extends EntityForm
{
protected array $config;
protected array $forms;
public function __construct(
EntityManagerInterface $em,
Serializer $serializer,
ValidatorInterface $validator,
Environment $environment,
Config $config,
Router $router
) {
$webhook_config = $config->get('webhooks');
$webhook_forms = [];
$config_injections = [
'router' => $router,
'triggers' => $webhook_config['triggers'],
'environment' => $environment,
];
foreach ($webhook_config['webhooks'] as $webhook_key => $webhook_info) {
$webhook_forms[$webhook_key] = $config->get('forms/webhook/' . $webhook_key, $config_injections);
}
parent::__construct($em, $serializer, $validator);
$this->config = $webhook_config;
$this->forms = $webhook_forms;
$this->entityClass = Entity\StationWebhook::class;
}
/**
* @return mixed[]
*/
public function getConfig(): array
{
return $this->config;
}
/**
* @return mixed[]
*/
public function getForms(): array
{
return $this->forms;
}
/**
* @inheritDoc
*/
public function process(ServerRequest $request, $record = null): object|bool
{
if (!$record instanceof Entity\StationWebhook) {
throw new InvalidArgumentException(
sprintf(
'Record is not an instance of %s',
Entity\StationWebhook::class
)
);
}
$this->configure($this->forms[$record->getType()]);
return parent::process($request, $record);
}
}

View File

@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Message;
use App\MessageQueue\QueueManagerInterface;
class AddNewPodcastMediaMessage extends AbstractUniqueMessage
{
/** @var int The numeric identifier for the StorageLocation entity. */
public int $storageLocationId;
/** @var string The relative path for the podcast media file to be processed. */
public string $path;
public function getQueue(): string
{
return QueueManagerInterface::QUEUE_PODCAST_MEDIA;
}
}

View File

@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Message;
use App\MessageQueue\QueueManagerInterface;
class ReprocessPodcastMediaMessage extends AbstractUniqueMessage
{
/** @var int The numeric identifier for the PodcastMedia record being processed. */
public int $podcastMediaId;
/** @var bool Whether to force reprocessing even if checks indicate it is not necessary. */
public bool $force = false;
public function getIdentifier(): string
{
return 'ReprocessPodcastMediaMessage_' . $this->podcastMediaId;
}
public function getQueue(): string
{
return QueueManagerInterface::QUEUE_PODCAST_MEDIA;
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Message;
use App\Environment;
use App\MessageQueue\QueueManagerInterface;
class TestWebhookMessage extends AbstractUniqueMessage
{
public int $webhookId;
/** @var string|null The path to log output of the Backup command to. */
public ?string $outputPath = null;
public function getIdentifier(): string
{
return 'TestWebHook_' . $this->webhookId;
}
public function getTtl(): ?float
{
return Environment::getInstance()->getSyncLongExecutionTime();
}
public function getQueue(): string
{
return QueueManagerInterface::QUEUE_NORMAL_PRIORITY;
}
}

View File

@ -10,8 +10,10 @@ use App\Exception;
use App\Http\RouterInterface;
use App\Message;
use Doctrine\ORM\EntityManagerInterface;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\TestHandler;
use Monolog\Logger;
use Psr\Log\LogLevel;
use Symfony\Component\Messenger\MessageBus;
class Dispatcher
@ -35,46 +37,60 @@ class Dispatcher
*/
public function __invoke(Message\AbstractMessage $message): void
{
if (!($message instanceof Message\DispatchWebhookMessage)) {
return;
}
$station = $this->em->find(Entity\Station::class, $message->station_id);
if (!$station instanceof Entity\Station) {
return;
}
$np = $message->np;
$triggers = (array)$message->triggers;
// Always dispatch the special "local" updater task.
$this->localHandler->dispatch($station, $np);
if ($this->environment->isTesting()) {
$this->logger->notice('In testing mode; no webhooks dispatched.');
return;
}
/** @var Entity\StationWebhook[] $enabledWebhooks */
$enabledWebhooks = $station->getWebhooks()->filter(
function (Entity\StationWebhook $webhook) {
return $webhook->isEnabled();
if ($message instanceof Message\DispatchWebhookMessage) {
$station = $this->em->find(Entity\Station::class, $message->station_id);
if (!$station instanceof Entity\Station) {
return;
}
);
$this->logger->debug('Webhook dispatch: triggering events: ' . implode(', ', $triggers));
$np = $message->np;
$triggers = (array)$message->triggers;
foreach ($enabledWebhooks as $webhook) {
$connectorObj = $this->connectors->getConnector($webhook->getType());
// Always dispatch the special "local" updater task.
$this->localHandler->dispatch($station, $np);
if ($connectorObj->shouldDispatch($webhook, $triggers)) {
$this->logger->debug(sprintf('Dispatching connector "%s".', $webhook->getType()));
if ($this->environment->isTesting()) {
$this->logger->notice('In testing mode; no webhooks dispatched.');
return;
}
if ($connectorObj->dispatch($station, $webhook, $np, $triggers)) {
$webhook->updateLastSentTimestamp();
$this->em->persist($webhook);
$this->em->flush();
/** @var Entity\StationWebhook[] $enabledWebhooks */
$enabledWebhooks = $station->getWebhooks()->filter(
function (Entity\StationWebhook $webhook) {
return $webhook->isEnabled();
}
);
$this->logger->debug('Webhook dispatch: triggering events: ' . implode(', ', $triggers));
foreach ($enabledWebhooks as $webhook) {
$connectorObj = $this->connectors->getConnector($webhook->getType());
if ($connectorObj->shouldDispatch($webhook, $triggers)) {
$this->logger->debug(sprintf('Dispatching connector "%s".', $webhook->getType()));
if ($connectorObj->dispatch($station, $webhook, $np, $triggers)) {
$webhook->updateLastSentTimestamp();
$this->em->persist($webhook);
$this->em->flush();
}
}
}
} elseif ($message instanceof Message\TestWebhookMessage) {
$outputPath = $message->outputPath;
if (null !== $outputPath) {
$logHandler = new StreamHandler($outputPath, LogLevel::DEBUG, true);
$this->logger->pushHandler($logHandler);
}
$webhook = $this->em->find(Entity\StationWebhook::class, $message->webhookId);
if ($webhook instanceof Entity\StationWebhook) {
$this->testDispatch($webhook);
}
if (null !== $outputPath) {
$this->logger->popHandler();
}
}
}
@ -83,16 +99,16 @@ class Dispatcher
* Send a "test" dispatch of the web hook, regardless of whether it is currently enabled, and
* return any logging information this yields.
*
* @param Entity\Station $station
* @param Entity\StationWebhook $webhook
*
* @throws Exception
*/
public function testDispatch(
Entity\Station $station,
Entity\StationWebhook $webhook
): TestHandler {
$handler = new TestHandler(Logger::DEBUG, false);
$station = $webhook->getStation();
$handler = new TestHandler(LogLevel::DEBUG, true);
$this->logger->pushHandler($handler);
$np = $this->nowPlayingApiGen->currentOrEmpty($station);

View File

@ -1,14 +0,0 @@
<?php
$this->layout('main', [
'title' => __('Add Web Hook')
]);
?>
<h3 class="mb-4"><?=__('Select the type of web hook to create.') ?></h3>
<dl>
<?php foreach($connectors as $type => $info): ?>
<dt class="mb-2"><a href="<?=$router->fromHere(null, ['type' => $type]) ?>" class="btn btn-primary"><?=$info['name'] ?></a></dt>
<dd class="pb-3"><?=$info['description'] ?></dd>
<?php endforeach; ?>
</dl>

View File

@ -1,74 +0,0 @@
<?php $this->layout('main', ['title' => __('Web Hooks'), 'manual' => true]) ?>
<div class="card">
<div class="card-header bg-primary-dark">
<h2 class="card-title"><?=__('Web Hooks')?></h2>
</div>
<div class="card-body alert-info d-flex align-items-center" role="alert">
<div class="flex-shrink-0 mr-2">
<i class="material-icons" aria-hidden="true">info</i>
</div>
<div class="flex-fill">
<p class="card-text">
<?=__('Web hooks let you connect to external web services and broadcast changes to your station to them.')?>
</p>
</div>
</div>
<div class="card-actions">
<a class="btn btn-outline-primary" role="button" href="<?=$router->fromHere('stations:webhooks:add')?>">
<i class="material-icons" aria-hidden="true">add</i>
<?=__('Add Web Hook')?>
</a>
</div>
<table class="table table-responsive-md table-striped mb-0">
<colgroup>
<col width="30%">
<col width="35%">
<col width="35%">
</colgroup>
<thead>
<tr>
<th><?=__('Actions')?></th>
<th><?=__('Name')?> / <?=__('Type')?></th>
<th><?=__('Triggers')?></th>
</tr>
</thead>
<tbody>
<?php foreach ($webhooks as $row): ?>
<?php /** @var \App\Entity\StationWebhook $row */ ?>
<tr class="align-middle">
<td>
<a class="btn btn-sm btn-primary" href="<?=$router->fromHere('stations:webhooks:edit',
['id' => $row->getId()])?>"><?=__('Edit')?></a>
<a class="btn btn-sm <?=($row->isEnabled() ? 'btn-warning' : 'btn-success')?>" href="<?=$router->fromHere('stations:webhooks:toggle',
[
'id' => $row->getId(),
'csrf' => $csrf,
])?>"><?=($row->isEnabled() ? __('Disable') : __('Enable'))?></a>
<a class="btn btn-sm btn-default" href="<?=$router->fromHere('stations:webhooks:test', [
'id' => $row->getId(),
'csrf' => $csrf,
])?>" title="<?=__('Trigger the web hook manually and view the raw response.')?>"><?=__('Test')?></a>
<a class="btn btn-sm btn-danger" data-confirm-title="<?=$this->e(__('Delete web hook "%s"?',
$row->getName()))?>" href="<?=$router->fromHere('stations:webhooks:delete',
['id' => $row->getId(), 'csrf' => $csrf])?>"><?=__('Delete')?></a>
</td>
<td>
<big><?=$this->e($row->getName())?></big><br>
<?=$webhook_config['webhooks'][$row->getType()]['name']?><?php if (!$row->isEnabled()): ?>
<span class="label label-danger"><?=__('Disabled')?></span><?php endif; ?>
</td>
<td>
<?php
$trigger_names = [];
foreach ((array)$row->getTriggers() as $trigger) {
$trigger_names[] = $webhook_config['triggers'][$trigger];
}
echo implode(', ', $trigger_names);
?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>