Archived
1
0
Fork 0

Use 4 spaces as tabs and reformat

This commit is contained in:
Varakh 2021-05-21 01:05:38 +02:00
parent df2d9520ca
commit 41234b34bf
34 changed files with 509 additions and 421 deletions

6
.editorconfig Normal file
View file

@ -0,0 +1,6 @@
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
indent_style = space
indent_size = 4

View file

@ -3,18 +3,22 @@
## 2.2.4 - UNRELEASED
## 2.2.3 - 2021/01/08
* Change docker base to alpine
## 2.2.2 - 2020/03/22
* Stop auto-sorting tables
* Add bandwidth formatter
* Check PHP 7.4 compatibility
* Increase docker image base to PHP 7.4
## 2.2.1 - 2019/11/10
* Use separate JavaScript file to initialize DataTables
## 2.2.0 - 2019/11/10
* Add version tag to footer
* Add sortable tables
* Add search on tables
@ -23,22 +27,27 @@
* Fix dependency version
## 2.1.4 - 2019/11/08
* Use autofocus on username input field instead of the password field
* Fill missing cells on incorrect cell count in table views when only partial data is available
## 2.1.3 - 2019/08/08
* Fixed false rendering of forms
* Fixed channel tree view showing the wrong virtual server after selection
* Minor code refactor
## 2.1.2 - 2019/08/07
* Minor refactoring
* Update documentation
## 2.1.1 - 2019/08/07
* Updated translation
## 2.1.0 - 2019/08/07
* Fixed file handling on snapshots
* Cleaned up template links
* Updated documentation
@ -49,6 +58,7 @@
* Removed about modal
## 2.0.0 - 2019/08/06
* Replace material design with bootstrap4 theme
* Add an about modal
* Add tree view for online clients
@ -56,34 +66,45 @@
* Update dependencies and force PHP 7.3
## 1.2.4 - 2019/01/18
* No info text
## 1.2.3 - 2019/01/18
* No info text
## 1.2.2 - 2018/09/01
* No info text
## 1.2.1 - 2018/06/04
* No info text
## 1.2.0 - 2018/06/04
* No info text
## 1.1.1 - 2018/05/04
* No info text
## 1.1.0 - 2018/05/04
* No info text
## 1.0.3 - 2018/04/04
* No info text
## 1.0.2 - 2018/04/04
* No info text
## 1.0.1 - 2018/04/03
* No info text
## 1.0.0 - 2018/04/03
* No info text

View file

@ -1,4 +1,5 @@
# README
ts3web is a free and open-source web interface for TeamSpeak 3 instances.
The minimalistic approach of this application is intentional.
@ -6,28 +7,35 @@ The minimalistic approach of this application is intentional.
* Docker images available on [https://hub.docker.com/r/varakh/ts3web](https://hub.docker.com/r/varakh/ts3web)
* Sources are hosted on [https://github.com/v4rakh/ts3web](https://github.com/v4rakh/ts3web)
There are many TeamSpeak 3 web interfaces out. Why should I pick ts3web?
Free, simple, stateless, easy to extend, standard bootstrap theme.
There are many TeamSpeak 3 web interfaces out. Why should I pick ts3web? Free, simple, stateless, easy to extend,
standard bootstrap theme.
## F.A.Q
Questions? Here you'll hopefully get the answer. Feel free to read before starting.
<a name="flood"></a>
###### I always get `flood client` message when clicking anywhere in the web interface.
ts3web makes heavy use of query commands. When your instance is up and running, you should be able to change
`serverinstance_serverquery_flood_commands` to a high value, e.g. `100` and `serverinstance_serverquery_flood_time` to
`1` which is enough.
<a name="whitelist"></a>
###### I always get `TSException: Error: host isn't a ts3 instance!` when selecting a server.
You probably got query banned from your server. You need to properly define your [`whitelist.txt` file](#whitelisttxtexample)
You probably got query banned from your server. You need to properly define
your [`whitelist.txt` file](#whitelisttxtexample)
and include it in your TeamSpeak application.
<a name="dockerperms"></a>
###### I always get `no write permissions` or something similar when trying to save snapshots or when a log entry is created.
This probably happens when you're in the docker setup. Ensure that host binds have permissions set up properly.
The user which is used in the docker container is `nobody` with id `65534`. If, e.g. logs are host bound, then execute
This probably happens when you're in the docker setup. Ensure that host binds have permissions set up properly. The user
which is used in the docker container is `nobody` with id `65534`. If, e.g. logs are host bound, then execute
`chown -R 65534:65534 host/path/to/log`. The same holds true for snapshots.
## Configuration
@ -38,6 +46,7 @@ server on `localhost` with default port. Docker deployments can and *should* hos
directly and just maintain the `env` file.
## Deployment
The application can be deployed two different ways. See below for more information. For each deployment type a running
TeamSpeak 3 instance is a prerequisite.
@ -55,7 +64,9 @@ In the `docker-compose.yml` [example](#dockercompose), a setup together with a t
`teamspeak_host=your-public-address` within the `env` file.
<a name="dockerrun"></a>
#### docker run
The following section outlines a manual setup. Feel free to use the provided `docker-compose.yml` as quick setup.
1. Create docker volumes for `snapshots`, `log` and `env`. Alternative is to host bind them into your containers.
@ -68,19 +79,24 @@ The following section outlines a manual setup. Feel free to use the provided `do
* `{snapshot_volume|host_folder}:/var/www/html/application/data/snapshots`
* `{log_volume|host_folder}:/var/www/html/application/log`
5. [Ensure that you're whitelisting the IP from which the webinterface will issue commands.](#whitelist)
6. Run the `docker run` command including your settings, volumes and networks (if any): `docker run --name teamspeak_web -v ./env:/var/www/html/application/config/env -p 8181:80 varakh/ts3web:latest`.
6. Run the `docker run` command including your settings, volumes and networks (if
any): `docker run --name teamspeak_web -v ./env:/var/www/html/application/config/env -p 8181:80 varakh/ts3web:latest`
.
<a name="dockercompose"></a>
#### docker-compose
In order for TeamSpeak to show correct IP and country flags, the `network_mode = "host"` is advised. It's also
possible to set everything up [without using the host network mode and use fixed IPs](#withouthostmode).
The examples will use host binds for volumes. Feel free to adapt the `docker-compose.yml` template and use docker volumes
instead if you like.
#### docker-compose
In order for TeamSpeak to show correct IP and country flags, the `network_mode = "host"` is advised. It's also possible
to set everything up [without using the host network mode and use fixed IPs](#withouthostmode).
The examples will use host binds for volumes. Feel free to adapt the `docker-compose.yml` template and use docker
volumes instead if you like.
Ensure to [apply permissions](#dockerperms) for volumes though.
<a name="withhostmode"></a>
#### With 'host' mode
```
@ -121,6 +137,7 @@ services:
```
<a name="withouthostmode"></a>
#### Without 'host' mode
```
@ -169,13 +186,13 @@ services:
```
<a name="whitelisttxtexample"></a>
#### whitelist.txt
The following illustrates a valid `whitelist.txt` file which can be used for the above `docker-compose` setups. You
need to replace `your-public-ip` with the TeamSpeak's public IP address if required or remove the fixed internal
docker IP if you're on 'host' mode.
The following illustrates a valid `whitelist.txt` file which can be used for the above `docker-compose` setups. You need
to replace `your-public-ip` with the TeamSpeak's public IP address if required or remove the fixed internal docker IP if
you're on 'host' mode.
```
127.0.0.1
@ -188,16 +205,19 @@ Now execute `docker-compose up -d` to start those containers. If you like to upd
`docker-compose pull` and then `docker-compose up -d` again.
Your TeamSpeak 3 Server will be available under `public-server-ip:9987`. The web interface will be available on
`127.0.0.1:8181`. You need to add a [reverse proxy](#reverseproxy) and probably you also want SSL configured if you expose it via domain.
For testing purposes, change `- 127.0.0.1:8181:80` to `- 8181:80`. The web interface will then be available under
`127.0.0.1:8181`. You need to add a [reverse proxy](#reverseproxy) and probably you also want SSL configured if you
expose it via domain. For testing purposes, change `- 127.0.0.1:8181:80` to `- 8181:80`. The web interface will then be
available under
`public-server-ip:8181`.
This is **not recommended**! Secure your setup properly via [reverse proxy and SSL](#reverseproxy).
### As native PHP application
**Prerequisite**: `php`, `composer` and probably `php-fpm` installed on the server.
#### Install:
* Clone repository
* Change directory to project home
* Execute `composer install`
@ -205,15 +225,19 @@ This is **not recommended**! Secure your setup properly via [reverse proxy and S
* Do the configuration by coping the `env.example` file (see information above)
* Use a web server _or_ run directly via the embedded PHP server: `php -S localhost:8080 -t public public/index.php`.
* Point your browser to [localhost:8080](http://localhost:8080)
* Apply any [whitelist.txt](#whitelisttxtexample) changes if you configured `teamspeak_host` differently than `localhost`
* Apply any [whitelist.txt](#whitelisttxtexample) changes if you configured `teamspeak_host` differently
than `localhost`
#### Upgrade:
* Change directory to project home
* `git pull`
* `composer update`
<a name="reverseproxy"></a>
### Reverse proxy
Here's an example on how to configure a reverse proxy for the web interface docker container
```
@ -256,6 +280,7 @@ supported:
If you're willing to contribute, here's some information.
### Release
* Set a date in the `CHANGELOG.md` file
* Remove `SNAPSHOT` from the version in `Constants.php`
* Build the docker image from the project
@ -274,7 +299,9 @@ If you're willing to contribute, here's some information.
4. Don't forget to clean up all created branches
### Helpers
Attributes can be defined when including `table`, `keyvalues` and `form` templates of twig. This helps to generate tables and forms without the need to specify all attributes.
Attributes can be defined when including `table`, `keyvalues` and `form` templates of twig. This helps to generate
tables and forms without the need to specify all attributes.
```
hiddenDependingOnAttribute // hides a row depending on a value in a table
@ -289,10 +316,15 @@ fields // define fields for a form
See example usage in the folder `View/bootstrap4`.
### Translations
- This app uses Symfony Translator. It's bootstrapped in `Util\BootstrapHelper` and locales are placed under `data/locale/` and the data table `.json` file, e.g. `en_dataTable.json`. Adjust to your needs or help translating.
- Form fields (name/id should be the same) are also translated. For a field named `content` or `ConT enT` translate `form_field_content`.
- This app uses Symfony Translator. It's bootstrapped in `Util\BootstrapHelper` and locales are placed
under `data/locale/` and the data table `.json` file, e.g. `en_dataTable.json`. Adjust to your needs or help
translating.
- Form fields (name/id should be the same) are also translated. For a field named `content` or `ConT enT`
translate `form_field_content`.
### Theme
Themes can be chosen in the `env` file by editing the `theme` variable. Templates are mapped to the corresponding view
folder in `src/View/<themeName>`. `.css`, `.js` and other style files like `.ttf` or `.woff2` for fonts should be placed
in `public/theme/<themeName>` and accessed accordingly. See an example in `src/View/boostrap4/layout.twig`.

View file

@ -1,6 +1,5 @@
<?php
use Carbon\Carbon;
use Slim\Http\Request;
use Slim\Http\Response;
use Symfony\Component\Filesystem\Exception\IOException;

View file

@ -47,7 +47,7 @@ class TSAuthAdapter extends \Zend\Authentication\Adapter\AbstractAdapter
if ($this->ts->login($user, $password)) {
$this->logger->debug(sprintf('Authenticated as %s', $user));
$user = ['identity' => $user, 'user' => $user, 'password'=> $password, 'role' => ACL::ACL_DEFAULT_ROLE_ADMIN];
$user = ['identity' => $user, 'user' => $user, 'password' => $password, 'role' => ACL::ACL_DEFAULT_ROLE_ADMIN];
return new Result(Result::SUCCESS, $user, array());
} else {
return new Result(

View file

@ -44,7 +44,8 @@ class FileHelper
* @param int $decimals
* @return string
*/
public static function humanFileSize($bytes, $decimals = 2) {
public static function humanFileSize($bytes, $decimals = 2)
{
$sz = 'BKMGTP';
$factor = floor((strlen($bytes) - 1) / 3);
return sprintf("%.{$decimals}f", $bytes / pow(1024, $factor)) . @$sz[$factor];
@ -57,7 +58,8 @@ class FileHelper
* @param int $decimals
* @return string
*/
public static function humanBandwidth($bytes, $decimals = 2) {
public static function humanBandwidth($bytes, $decimals = 2)
{
$sz = 'BKMGTP';
$factor = floor((strlen($bytes) - 1) / 3);
return sprintf("%.{$decimals}f", $bytes / pow(1024, $factor)) . @$sz[$factor] . '/s';

View file

@ -12,12 +12,14 @@ class TS3AdminProxy
* @param ts3admin $object
* @param $logger LoggerInterface
*/
public function __construct(ts3admin $object, $logger) {
public function __construct(ts3admin $object, $logger)
{
$this->object = $object;
$this->logger = $logger;
}
public function __call($method, $args) {
public function __call($method, $args)
{
// hide sensitive args
if (in_array($method, ['login'])) {

View file

@ -51,49 +51,6 @@ class TSInstance
}
}
/**
* Login
*
* @param $user
* @param $password
* @return bool
*/
public function login($user, $password)
{
if (!empty($user) && !empty($password)) {
$this->ts->login($user, $password);
$this->logger->debug(sprintf('Logged in as %s', $user));
} else {
throw new InvalidArgumentException('User and password not provided');
}
return true;
}
/**
* @return string
*/
public function getHost()
{
return $this->host;
}
/**
* @return int
*/
public function getQueryPort()
{
return $this->queryPort;
}
/**
* @return ts3admin
*/
public function getInstance()
{
return $this->ts;
}
/**
* @return array
*/
@ -224,4 +181,47 @@ class TSInstance
return $arr;
}
/**
* Login
*
* @param $user
* @param $password
* @return bool
*/
public function login($user, $password)
{
if (!empty($user) && !empty($password)) {
$this->ts->login($user, $password);
$this->logger->debug(sprintf('Logged in as %s', $user));
} else {
throw new InvalidArgumentException('User and password not provided');
}
return true;
}
/**
* @return string
*/
public function getHost()
{
return $this->host;
}
/**
* @return int
*/
public function getQueryPort()
{
return $this->queryPort;
}
/**
* @return ts3admin
*/
public function getInstance()
{
return $this->ts;
}
}

View file

@ -13,52 +13,110 @@ class Validator extends GUMP
$this->translator = BootstrapHelper::bootTranslator();
}
protected function validate_set_min_len($field, $input, $param = NULL)
{
$err = [
'field' => $field,
'value' => $input[$field],
'rule' => __FUNCTION__,
'param' => $param,
];
if (!is_array($input[$field])) {
return $err;
}
// default value
if (empty($param)) $param = 1;
if (count($input[$field]) < $param) return $err;
return true;
}
/** Validates if $field content is equal to $param
* @param $field
* @param $input
* @param $param
* @return bool
/**
* Perform data validation against the provided ruleset
*
* Arrays as FIELDS are added here as a custom feature
*
* @access public
* @param mixed $input
* @param array $ruleset
* @return mixed
* @throws \Exception
*/
protected function validate_equals($field, $input, $param)
public function validate(array $input, array $ruleset)
{
$err = [
'field' => $field,
'value' => $input[$field],
'rule' => __FUNCTION__,
'param' => $param,
];
$this->errors = [];
if (!isset($input[$field]) || empty($input[$field]) || empty($param) || !isset($param)) {
return $err;
foreach ($ruleset as $field => $rules) {
#if(!array_key_exists($field, $input))
#{
# continue;
#}
$rules = explode('|', $rules);
if (in_array("required", $rules) || (isset($input[$field]) && (is_array($input[$field]) || trim($input[$field]) != ''))) {
foreach ($rules as $rule) {
$method = NULL;
$param = NULL;
if (strstr($rule, ',') !== false) // has params
{
$rule = explode(',', $rule);
$method = 'validate_' . $rule[0];
$param = $rule[1];
$rule = $rule[0];
} else {
$method = 'validate_' . $rule;
}
if ($input[$field] != $param || $input[$field] !== $param) {
return $err;
// array required
if ($rule === "required" && !isset($input[$field])) {
$result = $this->$method($field, $input, $param);
$this->errors[] = $result;
return;
}
return true;
if (is_callable([$this, $method])) {
$result = $this->$method($field, $input, $param);
if (is_array($result)) // Validation Failed
{
$this->errors[] = $result;
return $this->errors;
}
} else {
if (isset(self::$validation_methods[$rule])) {
if (isset($input[$field])) {
$result = call_user_func(self::$validation_methods[$rule], $field, $input, $param);
$result = $this->$method($field, $input, $param);
if (is_array($result)) // Validation Failed
{
$this->errors[] = $result;
return $this->errors;
}
}
} else {
throw new \Exception("Validator method '$method' does not exist.");
}
}
}
}
}
return (count($this->errors) > 0) ? $this->errors : true;
}
public function filter_upper($value, $param = NULL)
{
return strtoupper($value);
}
public function filter_lower($value, $param = NULL)
{
return strtolower($value);
}
/**
* Converts all error array into a single string
* @return void
*/
public function addErrorsToFlashMessage($flash)
{
$errors = $this->get_errors_array(true);
if (!empty($errors)) {
foreach ($errors as $error) {
$flash->addMessage('error', $error);
}
}
}
/**
@ -171,112 +229,6 @@ class Validator extends GUMP
return $resp;
}
/**
* Perform data validation against the provided ruleset
*
* Arrays as FIELDS are added here as a custom feature
*
* @access public
* @param mixed $input
* @param array $ruleset
* @return mixed
* @throws \Exception
*/
public function validate(array $input, array $ruleset)
{
$this->errors = [];
foreach ($ruleset as $field => $rules) {
#if(!array_key_exists($field, $input))
#{
# continue;
#}
$rules = explode('|', $rules);
if (in_array("required", $rules) || (isset($input[$field]) && (is_array($input[$field]) || trim($input[$field]) != ''))) {
foreach ($rules as $rule) {
$method = NULL;
$param = NULL;
if (strstr($rule, ',') !== false) // has params
{
$rule = explode(',', $rule);
$method = 'validate_' . $rule[0];
$param = $rule[1];
$rule = $rule[0];
} else {
$method = 'validate_' . $rule;
}
// array required
if ($rule === "required" && !isset($input[$field])) {
$result = $this->$method($field, $input, $param);
$this->errors[] = $result;
return;
}
if (is_callable([$this, $method])) {
$result = $this->$method($field, $input, $param);
if (is_array($result)) // Validation Failed
{
$this->errors[] = $result;
return $this->errors;
}
} else {
if (isset(self::$validation_methods[$rule])) {
if (isset($input[$field])) {
$result = call_user_func(self::$validation_methods[$rule], $field, $input, $param);
$result = $this->$method($field, $input, $param);
if (is_array($result)) // Validation Failed
{
$this->errors[] = $result;
return $this->errors;
}
}
} else {
throw new \Exception("Validator method '$method' does not exist.");
}
}
}
}
}
return (count($this->errors) > 0) ? $this->errors : true;
}
public function filter_upper($value, $param = NULL)
{
return strtoupper($value);
}
public function filter_lower($value, $param = NULL)
{
return strtolower($value);
}
/**
* Converts all error array into a single string
* @return void
*/
public function addErrorsToFlashMessage($flash)
{
$errors = $this->get_errors_array(true);
if (!empty($errors)) {
foreach ($errors as $error) {
$flash->addMessage('error', $error);
}
}
}
/**
* @param array $input
* @param array $fields
@ -285,7 +237,7 @@ class Validator extends GUMP
*/
public function sanitize(array $input, array $fields = array(), $utf8_encode = true)
{
$magic_quotes = (bool) get_magic_quotes_gpc();
$magic_quotes = (bool)get_magic_quotes_gpc();
if (empty($fields)) {
$fields = array_keys($input);
@ -324,4 +276,52 @@ class Validator extends GUMP
return $return;
}
protected function validate_set_min_len($field, $input, $param = NULL)
{
$err = [
'field' => $field,
'value' => $input[$field],
'rule' => __FUNCTION__,
'param' => $param,
];
if (!is_array($input[$field])) {
return $err;
}
// default value
if (empty($param)) $param = 1;
if (count($input[$field]) < $param) return $err;
return true;
}
/** Validates if $field content is equal to $param
* @param $field
* @param $input
* @param $param
* @return bool
*/
protected function validate_equals($field, $input, $param)
{
$err = [
'field' => $field,
'value' => $input[$field],
'rule' => __FUNCTION__,
'param' => $param,
];
if (!isset($input[$field]) || empty($input[$field]) || empty($param) || !isset($param)) {
return $err;
}
if ($input[$field] != $param || $input[$field] !== $param) {
return $err;
}
return true;
}
}

View file

@ -27,7 +27,8 @@
<td>{{ path }}</td>
<td>{{ file.size == 0 ? '' : file.size|file }}</td>
<td>{{ file.datetime|timestamp }}</td>
<td><a href="/channels/files/delete/{{ sid }}/{{ cid }}?file={{ path }}"><i class="fa fa-trash"></i></a></td>
<td><a href="/channels/files/delete/{{ sid }}/{{ cid }}?file={{ path }}"><i class="fa fa-trash"></i></a>
</td>
</tr>
{% endfor %}
<tr>

View file

@ -1,7 +1,7 @@
{% extends 'layout.twig' %}
{% block content %}
<h3 >{{ title }}</h3>
<h3>{{ title }}</h3>
{% include 'keyvalue.twig' with {'data': data,
'filters': [
{'key': 'instance_uptime', 'apply': 'timeInSeconds'},

View file

@ -20,7 +20,8 @@
</div>
<div class="form-group">
<label for="username" class="sr-only">{% trans %}login.form.username{% endtrans %}</label>
<label for="username"
class="sr-only">{% trans %}login.form.username{% endtrans %}</label>
<input type="text" id="username" name="username" class="form-control form-control-sm"
placeholder="{{ getenv('teamspeak_user') }}"
value="{{ getenv('teamspeak_user') }}"
@ -28,7 +29,8 @@
</div>
<div class="form-group">
<label for="password" class="sr-only">{% trans %}login.form.password{% endtrans %}</label>
<label for="password"
class="sr-only">{% trans %}login.form.password{% endtrans %}</label>
<input type="password" id="password" name="password"
class="form-control form-control-sm"
placeholder="{% trans %}login.form.password.placeholder{% endtrans %}" required>

View file

@ -16,7 +16,7 @@
{% endif %}
{% if appLog|length > 0 %}
<br />
<br/>
<h3>{% trans %}app_log.title{% endtrans %}</h3>
<div class="small">
{% for line in appLog %}

View file

@ -9,49 +9,67 @@
<ul class="navbar-nav ml-auto">
{% if currentUser is not empty %}
<li class="nav-item"><a class="nav-link" href="/instance"><i class="fa fa-info"></i> {% trans %}menu.instance{% endtrans %}</a>
<li class="nav-item"><a class="nav-link" href="/instance"><i
class="fa fa-info"></i> {% trans %}menu.instance{% endtrans %}</a>
</li>
<li class="nav-item"><a class="nav-link" href="/logs"><i class="fa fa-file"></i> {% trans %}menu.logs{% endtrans %}</a></li>
<li class="nav-item"><a class="nav-link" href="/logs"><i
class="fa fa-file"></i> {% trans %}menu.logs{% endtrans %}</a></li>
<li class="nav-item dropdown">
{% if session_exists('sid') %}
{% if session_exists('sport') %}
<a class="nav-link dropdown-toggle" href="#" id="dropdownServer" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false"><i class="fa fa-star"></i> {{ session_get('sport') }}</a>
aria-haspopup="true" aria-expanded="false"><i
class="fa fa-star"></i> {{ session_get('sport') }}</a>
{% elseif session_exists('sname') %}
<a class="nav-link dropdown-toggle" href="#" id="dropdownServer" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false"><i class="fa fa-star"></i> {{ session_get('sname') }}</a>
aria-haspopup="true" aria-expanded="false"><i
class="fa fa-star"></i> {{ session_get('sname') }}</a>
{% endif %}
<div class="dropdown-menu" aria-labelledby="dropdownServer">
<a class="dropdown-item"
href="/servers/{{ session_get('sid') }}"><i class="fa fa-info"></i> {% trans %}menu.servers.info{% endtrans %}</a>
href="/servers/{{ session_get('sid') }}"><i
class="fa fa-info"></i> {% trans %}menu.servers.info{% endtrans %}</a>
<a class="dropdown-item"
href="/online/{{ session_get('sid') }}"><i class="fa fa-signal"></i> {% trans %}menu.servers.online{% endtrans %}</a>
href="/online/{{ session_get('sid') }}"><i
class="fa fa-signal"></i> {% trans %}menu.servers.online{% endtrans %}</a>
<a class="dropdown-item"
href="/clients/{{ session_get('sid') }}"><i class="fa fa-user-plus"></i> {% trans %}menu.servers.clients{% endtrans %}</a>
href="/clients/{{ session_get('sid') }}"><i
class="fa fa-user-plus"></i> {% trans %}menu.servers.clients{% endtrans %}</a>
<a class="dropdown-item"
href="/groups/{{ session_get('sid') }}"><i class="fa fa-group"></i> {% trans %}menu.servers.groups{% endtrans %}</a>
href="/groups/{{ session_get('sid') }}"><i
class="fa fa-group"></i> {% trans %}menu.servers.groups{% endtrans %}</a>
<a class="dropdown-item"
href="/channels/{{ session_get('sid') }}"><i class="fa fa-tv"></i> {% trans %}menu.servers.channels{% endtrans %}</a>
href="/channels/{{ session_get('sid') }}"><i
class="fa fa-tv"></i> {% trans %}menu.servers.channels{% endtrans %}</a>
<a class="dropdown-item"
href="/bans/{{ session_get('sid') }}"><i class="fa fa-ban"></i> {% trans %}menu.servers.bans{% endtrans %}</a>
href="/bans/{{ session_get('sid') }}"><i
class="fa fa-ban"></i> {% trans %}menu.servers.bans{% endtrans %}</a>
<a class="dropdown-item"
href="/complains/{{ session_get('sid') }}"><i class="fa fa-thumbs-down"></i> {% trans %}menu.servers.complains{% endtrans %}</a>
href="/complains/{{ session_get('sid') }}"><i
class="fa fa-thumbs-down"></i> {% trans %}menu.servers.complains{% endtrans %}
</a>
<a class="dropdown-item"
href="/passwords/{{ session_get('sid') }}"><i class="fa fa-key"></i> {% trans %}menu.servers.passwords{% endtrans %}</a>
href="/passwords/{{ session_get('sid') }}"><i
class="fa fa-key"></i> {% trans %}menu.servers.passwords{% endtrans %}</a>
<a class="dropdown-item"
href="/tokens/{{ session_get('sid') }}"><i class="fa fa-user-secret"></i> {% trans %}menu.servers.tokens{% endtrans %}</a>
href="/tokens/{{ session_get('sid') }}"><i
class="fa fa-user-secret"></i> {% trans %}menu.servers.tokens{% endtrans %}</a>
<a class="dropdown-item"
href="/snapshots/{{ session_get('sid') }}"><i class="fa fa-save"></i> {% trans %}menu.servers.snapshots{% endtrans %}</a>
href="/snapshots/{{ session_get('sid') }}"><i
class="fa fa-save"></i> {% trans %}menu.servers.snapshots{% endtrans %}</a>
<a class="dropdown-item"
href="/logs/{{ session_get('sid') }}"><i class="fa fa-file"></i> {% trans %}menu.servers.logs{% endtrans %}</a>
href="/logs/{{ session_get('sid') }}"><i
class="fa fa-file"></i> {% trans %}menu.servers.logs{% endtrans %}</a>
<hr/>
<a class="dropdown-item" href="/servers/deselect"><i class="fa fa-close"></i> {% trans %}menu.servers.deselect{% endtrans %}</a>
<a class="dropdown-item" href="/servers/deselect"><i
class="fa fa-close"></i> {% trans %}menu.servers.deselect{% endtrans %}</a>
<hr/>
<a class="dropdown-item"
href="/servers"><i class="fa fa-server"></i> {% trans %}menu.servers{% endtrans %}</a>
href="/servers"><i class="fa fa-server"></i> {% trans %}menu.servers{% endtrans %}
</a>
</div>
{% else %}
@ -61,7 +79,8 @@
aria-expanded="false">{% trans %}menu.servers.select{% endtrans %}</a>
<div class="dropdown-menu" aria-labelledby="dropdownServer">
<a class="dropdown-item"
href="/servers"><i class="fa fa-server"></i> {% trans %}menu.servers{% endtrans %}</a>
href="/servers"><i class="fa fa-server"></i> {% trans %}menu.servers{% endtrans %}
</a>
</div>
{% endif %}
</li>
@ -69,14 +88,18 @@
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="dropdownUser" data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"><i class="fa fa-user"></i> {% trans with {'%username%': currentUser.user } %}menu.currentlyloggedin{% endtrans %}</a>
aria-expanded="false"><i
class="fa fa-user"></i> {% trans with {'%username%': currentUser.user } %}menu.currentlyloggedin{% endtrans %}
</a>
<div class="dropdown-menu" aria-labelledby="dropdownUser">
<a class="dropdown-item" href="/logout"><i class="fa fa-sign-out"></i> {% trans %}menu.logout{% endtrans %}</a>
<a class="dropdown-item" href="/logout"><i
class="fa fa-sign-out"></i> {% trans %}menu.logout{% endtrans %}</a>
</div>
</li>
{% else %}
<li class="nav-item"><a class="nav-link" href="/login"><i class="fa fa-sign-in"></i> {% trans %}menu.login{% endtrans %}</a></li>
<li class="nav-item"><a class="nav-link" href="/login"><i
class="fa fa-sign-in"></i> {% trans %}menu.login{% endtrans %}</a></li>
{% endif %}
</ul>
</div>