This commit is contained in:
Nicolas Debrigode 2024-02-05 19:23:59 +01:00
parent bfe57232dc
commit b79da8b937
27 changed files with 528 additions and 78 deletions

1
asset/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
*

View File

@ -4,6 +4,10 @@ config:
knownlinksfile: '/home/nidebr/data/DEV/asstats/conf/knownlinks'
asinfofile: 'ressources/asinfo.db'
myasn: 34863
asset:
whois: '/usr/bin/whois'
assetpath: 'asset'
asset_cache_life: 604800 # 604800 seconds = 7 days
graph:
showv6: true
outispositive: false
@ -21,7 +25,7 @@ config:
top_graph_width: 600
top_graph_height: 220
linksusage:
top: 3
top: 10
color:
- A6CEE3
- 1F78B4

View File

@ -18,10 +18,12 @@
"symfony/http-client": "7.0.*",
"symfony/process": "7.0.*",
"symfony/runtime": "7.0.*",
"symfony/translation": "7.0.*",
"symfony/twig-bundle": "7.0.*",
"symfony/validator": "7.0.*",
"symfony/yaml": "7.0.*",
"twig/extra-bundle": "^3.0",
"twig/intl-extra": "^3.8",
"twig/string-extra": "^3.0",
"twig/twig": "^3.0"
},

View File

@ -0,0 +1,7 @@
framework:
default_locale: en
translator:
default_path: '%kernel.project_dir%/translations'
fallbacks:
- en
- fr

View File

@ -16,3 +16,4 @@ parameters:
ignoreErrors:
- '#Only booleans are allowed#'
- '#Cannot cast mixed to int.#'
- '#Cannot cast mixed to string.#'

View File

@ -66,6 +66,15 @@ class ConfigApplication
return self::getAsStatsConfig()['top'];
}
public static function getLangage(): string
{
if (false === \array_key_exists('langage', self::getAsStatsConfig())) {
return 'en';
}
return self::getAsStatsConfig()['langage'];
}
public static function getAsStatsConfigMyAsn(): ?int
{
if (false === \array_key_exists('myasn', self::getAsStatsConfig())) {
@ -174,4 +183,43 @@ class ConfigApplication
return self::getAsStatsConfig()['linksusage']['top'];
}
public static function getAsStatsAssetPath(): string
{
if (false === \array_key_exists('asset', self::getAsStatsConfig())) {
throw new ConfigErrorException('Unable to found config.asset variable');
}
if (false === \array_key_exists('assetpath', self::getAsStatsConfig()['asset'])) {
throw new ConfigErrorException('Unable to found config.asset.assetpath variable');
}
return self::getRootPathApp().self::getAsStatsConfig()['asset']['assetpath'];
}
public static function getAsStatsAssetCacheLife(): int
{
if (false === \array_key_exists('asset', self::getAsStatsConfig())) {
throw new ConfigErrorException('Unable to found config.asset variable');
}
if (false === \array_key_exists('asset_cache_life', self::getAsStatsConfig()['asset'])) {
throw new ConfigErrorException('Unable to found config.asset.asset_cache_life variable');
}
return self::getAsStatsConfig()['asset']['asset_cache_life'];
}
public static function getAsStatsAssetWhois(): string
{
if (false === \array_key_exists('asset', self::getAsStatsConfig())) {
throw new ConfigErrorException('Unable to found config.asset variable');
}
if (false === \array_key_exists('whois', self::getAsStatsConfig()['asset'])) {
throw new ConfigErrorException('Unable to found config.asset.whois variable');
}
return self::getAsStatsConfig()['asset']['whois'];
}
}

View File

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Form\SearchASSetForm;
use App\Repository\AsSetRepository;
use App\Repository\GetAsDataRepository;
use App\Repository\KnowlinksRepository;
use App\Util\Annotation\Menu;
use App\Util\GetStartEndGraph;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
#[Menu('asset')]
class AssetController extends BaseController
{
protected array $data = [];
#[Route(
path: '/asset',
name: 'asset',
methods: ['GET|POST'],
)]
public function index(
Request $request,
AsSetRepository $asSetRepository,
GetAsDataRepository $asDataRepository,
GetStartEndGraph $getStartEndGraph,
): Response {
$this->base_data['content_wrapper']['titre'] = 'History for AS-SET';
$form = $this->createForm(SearchASSetForm::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->data['asset']['name'] = \strtoupper((string) $form->get('asset')->getData());
$this->data['asset']['whois'] = $asSetRepository->getAsset($this->data['asset']['name']);
if (\array_key_exists('as_num', $this->data['asset']['whois'])) {
$this->data['asset']['data'] = $asDataRepository::get(200, '', [], $this->data['asset']['whois']['as_num']);
$this->data['selectedLinks'] = [];
$this->data['graph_size'] = [
'width' => $this->configApplication::getAsStatsConfigGraph()['top_graph_width'],
'height' => $this->configApplication::getAsStatsConfigGraph()['top_graph_height'],
];
$this->data = \array_merge($this->data, $getStartEndGraph->get());
return $this->render('pages/asset/show.html.twig', [
'base_data' => $this->base_data,
'data' => $this->data,
'form' => $form->createView(),
'knownlinks' => KnowlinksRepository::get(),
]);
}
$this->addFlash('info', \sprintf('Unable to find information about asset %s', $this->data['asset']['name']));
}
return $this->render('pages/asset/index.html.twig', [
'base_data' => $this->base_data,
'data' => $this->data,
'form' => $form->createView(),
]);
}
}

View File

@ -10,6 +10,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormView;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Translation\LocaleSwitcher;
abstract class BaseController extends AbstractController
{
@ -19,9 +20,12 @@ abstract class BaseController extends AbstractController
public function __construct(
ConfigApplication $configApplication,
RequestStack $requestStack,
LocaleSwitcher $localeSwitcher,
) {
$this->configApplication = $configApplication;
$this->base_data = self::getBaseData($requestStack);
$localeSwitcher->setLocale($this->configApplication::getLangage());
}
private function getBaseData(

View File

@ -56,6 +56,10 @@ class HistoryController extends BaseController
$this->data['as'] = $as;
$this->data['asinfo'] = $asInfoRepository->getAsInfo($this->data['as']);
if ('UNKNOWN' === $this->data['asinfo']['name']) {
$this->addFlash('error', \sprintf('Unable to find AS%s in database ASInfo, please update.', $this->data['as']));
}
$this->base_data['content_wrapper']['titre'] = \sprintf(
'History for AS%s',
$this->data['as'],

View File

@ -14,6 +14,7 @@ use App\Repository\KnowlinksRepository;
use App\Repository\PeeringDBRepository;
use App\Util\Annotation\Menu;
use App\Util\GetJsonParameters;
use App\Util\GetStartEndGraph;
use Doctrine\DBAL\Exception;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
@ -43,6 +44,7 @@ class IXStatsController extends BaseController
Request $request,
PeeringDBRepository $peeringDBRepository,
GetAsDataRepository $asDataRepository,
GetStartEndGraph $getStartEndGraph,
): Response {
$this->base_data['content_wrapper']['titre'] = 'My IX Stats';
@ -66,14 +68,14 @@ class IXStatsController extends BaseController
$peeringDBRepository->getIXMembers((int) $form->get('myix')->getData()),
);
$this->data['start'] = time() - 24 * 3600;
$this->data['end'] = time();
$this->data['graph_size'] = [
'width' => $this->configApplication::getAsStatsConfigGraph()['top_graph_width'],
'height' => $this->configApplication::getAsStatsConfigGraph()['top_graph_height'],
];
$this->data['selectedLinks'] = [];
$this->data = \array_merge($this->data, $getStartEndGraph->get());
return $this->render('pages/ix/my_ix/show.html.twig', [
'base_data' => $this->base_data,
'data' => $this->data,
@ -103,6 +105,7 @@ class IXStatsController extends BaseController
Request $request,
PeeringDBRepository $peeringDBRepository,
GetAsDataRepository $asDataRepository,
GetStartEndGraph $getStartEndGraph,
): Response {
$this->base_data['content_wrapper']['titre'] = 'Search IX Stats';
@ -125,14 +128,14 @@ class IXStatsController extends BaseController
$peeringDBRepository->getIXMembers((int) $form->get('ix_hidden')->getData()),
);
$this->data['start'] = time() - 24 * 3600;
$this->data['end'] = time();
$this->data['graph_size'] = [
'width' => $this->configApplication::getAsStatsConfigGraph()['top_graph_width'],
'height' => $this->configApplication::getAsStatsConfigGraph()['top_graph_height'],
];
$this->data['selectedLinks'] = [];
$this->data = \array_merge($this->data, $getStartEndGraph->get());
return $this->render('pages/ix/search_ix/show.html.twig', [
'base_data' => $this->base_data,
'data' => $this->data,

View File

@ -16,6 +16,7 @@ use Doctrine\DBAL\Exception;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\Translation\TranslatorInterface;
#[Menu('top_as')]
class IndexController extends BaseController
@ -37,11 +38,12 @@ class IndexController extends BaseController
Request $request,
GetAsDataRepository $asDataRepository,
GetStartEndGraph $getStartEndGraph,
TranslatorInterface $translator,
): Response {
$this->base_data['content_wrapper']['titre'] = \sprintf(
'Top %s (%s)',
$this->base_data['top'],
'24 hours'
$translator->trans('hour', ['num_hours' => 24]),
);
$form = $this->createForm(LegendForm::class);

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class SearchASSetForm extends AbstractType
{
/**
* @param array<string, mixed> $options
*/
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->setMethod('get')
->add('asset', TextType::class, [
'attr' => [
'placeholder' => 'Search AS-SET',
],
'translation_domain' => false,
'label' => false,
]);
}
public function getBlockPrefix(): string
{
return '';
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'csrf_protection' => false,
]);
}
}

View File

@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Application\ConfigApplication;
use App\Exception\ConfigErrorException;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
class AsSetRepository
{
private ConfigApplication $config;
public function __construct(ConfigApplication $config)
{
$this->config = $config;
}
/**
* @throws ConfigErrorException
*/
public function getAsset(string $asset): array
{
/* sanity check */
if (!\preg_match('/^[a-zA-Z0-9:_-]+$/', $asset)) {
return [];
}
$cache = false;
$assetfile = \sprintf('%s/%s.txt', $this->config::getAsStatsAssetPath(), $asset);
if (\file_exists($assetfile)) {
$filemtime = \filemtime($assetfile);
if (!$filemtime || (\time() - $filemtime >= $this->config::getAsStatsAssetCacheLife())) {
$list = $this->getWhois($asset);
} else {
$list = $this->readCacheFile($asset);
$cache = true;
if ([] === $list) {
$list = $this->getWhois($asset);
}
}
} else {
$list = $this->getWhois($asset);
}
return \array_merge(['cache' => $cache], $list);
}
/**
* @throws ConfigErrorException
*/
private function getWhois(string $asset): array
{
$process = new Process([$this->config::getAsStatsAssetWhois(), '-h', 'whois.radb.net', \sprintf('!i%s', $asset)]);
$process->run();
if (!$process->isSuccessful()) {
throw new ProcessFailedException($process);
}
$return_list = \explode(' ', \trim(\str_replace(PHP_EOL, ' ', $process->getOutput())));
/* write cache file */
$this->writeCacheFile($asset, $process->getOutput());
return $this->parseOtherAsset($this->parseData($return_list));
}
private function parseData(array $asnlist): array
{
$return = [];
foreach ($asnlist as $asn) {
if (\str_starts_with($asn, 'AS')) {
$return[] = $asn;
}
}
return $return;
}
private function writeCacheFile(string $asset, string $asnlist): void
{
if ('' !== $asset && '0' !== $asset) {
\file_put_contents(\sprintf('%s/%s.txt', $this->config::getAsStatsAssetPath(), $asset), $asnlist);
}
}
private function readCacheFile(string $asset): array
{
$return = [];
if ('' !== $asset && '0' !== $asset) {
$input = \file_get_contents(\sprintf('%s/%s.txt', $this->config::getAsStatsAssetPath(), $asset));
if (!$input) {
return [];
}
$return = \explode(' ', \trim(\str_replace(PHP_EOL, ' ', $input)));
}
return $this->parseOtherAsset($this->parseData($return));
}
private function parseOtherAsset(array $aslist): array
{
$return = [];
foreach ($aslist as $as) {
$as_tmp = \substr($as, 2);
if (\is_numeric($as_tmp)) {
$return['as_num'][] = $as_tmp;
} else {
$return['as_other'][] = $as;
}
}
return $return;
}
}

View File

@ -87,12 +87,23 @@ class DbAsInfoRepository
public function getAsInfo(int $asn): array
{
try {
return (array) $this->cnx->createQueryBuilder()
$return = $this->cnx->createQueryBuilder()
->select('*')
->from('asinfo')
->where('asn = :asn')
->setParameter('asn', $asn)
->fetchAssociative();
if (false === $return) {
$return = [
'asn' => $asn,
'country' => 'EU',
'name' => 'UNKNOWN',
'description' => 'UNKNOWN',
];
}
return (array) $return;
} catch (Exception) {
throw new DbErrorException(\sprintf('Problem with ASInfo DB files %s', $this->dbname));
}

View File

@ -11,6 +11,7 @@ enum Icon: string
case menu_view = 'ti-chart-histogram icon';
case menu_ix = 'ti-list-details icon';
case menu_linksusage = 'ti-package icon';
case menu_asset = 'ti-file-neutral icon';
// Form
case filter = 'ti-filter';

View File

@ -106,6 +106,19 @@
"config/routes.yaml"
]
},
"symfony/translation": {
"version": "7.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.3",
"ref": "e28e27f53663cc34f0be2837aba18e3a1bef8e7b"
},
"files": [
"config/packages/translation.yaml",
"translations/.gitignore"
]
},
"symfony/twig-bundle": {
"version": "7.0",
"recipe": {

View File

@ -46,6 +46,17 @@
</a>
</li>
<li class="nav-item {% if "asset" == current_menu %}active{% endif %}">
<a class="nav-link" href="{{ path('asset') }}">
<span class="nav-link-icon d-md-none d-lg-inline-block">
{{ icon('menu_asset') }}
</span>
<span class="nav-link-title">
View AS-SET
</span>
</a>
</li>
<li class="nav-item dropdown {% if "view_ix" == current_menu %}active{% endif %}">
<a class="nav-link dropdown-toggle" href="#" data-bs-toggle="dropdown" data-bs-auto-close="outside" role="button" aria-expanded="false" >
<span class="nav-link-icon d-md-none d-lg-inline-block">

View File

@ -1,72 +1,69 @@
<div class="card">
<div class="row g-0">
<div class="col-1">
<div class="card-body {% if (block('rank') is defined and block('rank') is not empty) %}ps-10{% endif %}">
<div class="row">
<div class="col">
{% if block('title') is defined and block('title') is not empty %}
{% block title %}{% endblock %}
{% endif %}
</div>
</div>
<div class="row">
{% if configapplication_graph('showv6') %}
{% set col_ipv4 = 'col-lg-6 col-sm-12' %}
{% else %}
{% set col_ipv4 = 'col-lg-12' %}
{% endif %}
<div class="{{ col_ipv4 }}">
{% set title = "AS#{as} - #{as_data.info.description|default('')} - IPv4" %}
<div class="text-center">
<a href="{{ path('history.as', {'as': as}) }}">
{{ gen_graph(as, 4, title, data.start, data.end, data.selectedLinks|concat_link, data.graph_size.width, data.graph_size.height, configapplication_graph('showlegendontop')) }}
</a>
</div>
</div>
{% if configapplication_graph('showv6') %}
<div class="col-lg-6 col-sm-12">
{% set title = "AS#{as} - #{as_data.info.description|default('')} - IPv6" %}
<div class="text-center">
<a href="{{ path('history.as', {'as': as}) }}">
{{ gen_graph(as, 6, title, data.start, data.end, data.selectedLinks|concat_link, data.graph_size.width, data.graph_size.height, configapplication_graph('showlegendontop')) }}
</a>
</div>
</div>
{% endif %}
</div>
<div class="row">
<div class="col-md">
<div class="mt-3 list-inline list-inline-dots mb-0 text-secondary d-sm-block d-none">
<div class="list-inline-item">
{{ icon('top_hours', 'icon icon-inline fix-icon-inline') }}
In the last {{ hours }}
</div>
<div class="list-inline-item">
{{ icon('top_ip', 'icon icon-inline fix-icon-inline') }}
IPv4: ~ {{ as_data.v4.in|format_bytes }} in / {{ as_data.v4.out|format_bytes }} out
</div>
{% if configapplication_graph('showv6') %}
<div class="list-inline-item">
{{ icon('top_ip', 'icon icon-inline fix-icon-inline') }}
IPv6: ~ {{ as_data.v6.in|format_bytes }} in / {{ as_data.v6.out|format_bytes }} out
</div>
{% endif %}
</div>
</div>
{% if block('customlinks') is defined and block('customlinks') is not empty %}
{% block customlinks %}{% endblock %}
{% endif %}
</div>
{% if (block('rank') is defined and block('rank') is not empty) %}
<div class="card-body">
<div class="ribbon">
{% block rank %}{% endblock %}
</div>
{% endif %}
</div>
<div class="col">
<div class="card-body {% if (block('rank') is defined and block('rank') is not empty) %}ps-0{% endif %}">
<div class="row">
<div class="col">
{% if block('title') is defined and block('title') is not empty %}
{% block title %}{% endblock %}
{% endif %}
</div>
</div>
<div class="row">
{% if configapplication_graph('showv6') %}
{% set col_ipv4 = 'col-lg-6 col-sm-12' %}
{% else %}
{% set col_ipv4 = 'col-lg-12' %}
{% endif %}
<div class="{{ col_ipv4 }}">
{% set title = "AS#{as} - #{as_data.info.description|default('')} - IPv4" %}
<div class="text-center">
<a href="{{ path('history.as', {'as': as}) }}">
{{ gen_graph(as, 4, title, data.start, data.end, data.selectedLinks|concat_link, data.graph_size.width, data.graph_size.height, configapplication_graph('showlegendontop')) }}
</a>
</div>
</div>
{% if configapplication_graph('showv6') %}
<div class="col-lg-6 col-sm-12">
{% set title = "AS#{as} - #{as_data.info.description|default('')} - IPv6" %}
<div class="text-center">
<a href="{{ path('history.as', {'as': as}) }}">
{{ gen_graph(as, 6, title, data.start, data.end, data.selectedLinks|concat_link, data.graph_size.width, data.graph_size.height, configapplication_graph('showlegendontop')) }}
</a>
</div>
</div>
{% endif %}
</div>
<div class="row">
<div class="col-md">
<div class="mt-3 list-inline list-inline-dots mb-0 text-secondary d-sm-block d-none">
<div class="list-inline-item">
{{ icon('top_hours', 'icon icon-inline fix-icon-inline') }}
In the last {{ hours }}
</div>
<div class="list-inline-item">
{{ icon('top_ip', 'icon icon-inline fix-icon-inline') }}
IPv4: ~ {{ as_data.v4.in|format_bytes }} in / {{ as_data.v4.out|format_bytes }} out
</div>
{% if configapplication_graph('showv6') %}
<div class="list-inline-item">
{{ icon('top_ip', 'icon icon-inline fix-icon-inline') }}
IPv6: ~ {{ as_data.v6.in|format_bytes }} in / {{ as_data.v6.out|format_bytes }} out
</div>
{% endif %}
</div>
</div>
{% if block('customlinks') is defined and block('customlinks') is not empty %}
{% block customlinks %}{% endblock %}
{% endif %}
</div>
</div>
</div>
</div>
</div>

View File

@ -3,10 +3,10 @@
{{ form_start(form.legend) }}
<div class="card">
<div class="card-body">
<h3 class="card-title">
<strong>Legend</strong>
</h3>
<div class="table-responsive">
<div class="ribbon">
Legend
</div>
<div class="table-responsive mt-5">
<table class="table table-borderless table-sm">
<tbody>
{% if knownlinks is defined %}

View File

@ -0,0 +1,15 @@
{{ form_start(form) }}
<div class="card">
<div class="card-body">
<h3 class="card-title">Search AS-SET</h3>
<div class="row">
<div class="col">
{{ form_widget(form.asset, {'value': data.asset.name|default('')}) }}
</div>
<div class="col-auto">
<button type="submit" class="btn btn-icon">{{ icon('search', 'icon') }}</button>
</div>
</div>
</div>
</div>
{{ form_end(form) }}

View File

@ -0,0 +1,22 @@
{% extends "base/_layout.html.twig" %}
{% block content %}
<div class="row row-cards">
<div class="col-lg-2 col-sm-12">
<div class="row row-cards">
<div class="col-lg-12">
{% include 'pages/asset/_search.html.twig' %}
</div>
<div class="col-lg-12">
{% block legend %}{% endblock %}
</div>
<div class="col-lg-12">
{% block other_asset %}{% endblock %}
</div>
</div>
</div>
<div class="col-lg-10 col-sm-12">
{% block graphs %}{% endblock %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,60 @@
{% extends "pages/asset/index.html.twig" %}
{% set counter = 0 %}
{% block legend %}
{% if knownlinks is defined %}
{% include 'core/legend_simple.html.twig' %}
{% endif %}
{% endblock %}
{% block other_asset %}
{% if data.asset.whois.as_other is defined %}
<div class="card card-sm">
<div class="card-body">
<h3 class="card-title">
<strong>Other AS-SET</strong>
</h3>
<div class="list-group list-group-flush">
{% for asset in data.asset.whois.as_other %}
<a href="#" class="list-group-item list-group-item-action">{{ asset }}</a>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block graphs %}
<div class="col">
<div class="row row-cards">
<div class="space-y">
{% for as, as_data in data.asset.data.asinfo %}
{% set counter = counter + 1 %}
{% embed 'core/card_top.html.twig' with {as: as, as_data: as_data, data: data, counter: counter, hours: hours|default('24 hours')} only %}
{% block rank %}
# {{ counter }}
{% endblock %}
{% block title %}
<h3 class="mb-0">
<span class="me-2 flag flag-{{ as_data.info.country|default('eu')|lower }}"></span>
<strong>AS{{ as }}:</strong>
<span class="small text-uppercase"><i>{{ as_data.info.name|default(as_data.info.description|default('')) }}</i></span>
</h3>
{% endblock %}
{% block customlinks %}
<div class="col-md-auto">
<div class="mt-3 badges">
{{ custom_links_top(as)|raw }}
</div>
</div>
{% endblock %}
{% endembed %}
{% endfor %}
</div>
</div>
</div>
{% endblock %}

View File

@ -2,7 +2,7 @@
<div class="card">
<div class="card-body">
<h3 class="card-title">Search AS</h3>
<div class="row g2">
<div class="row">
<div class="col">
{{ form_widget(form.as, {'value': data.as|default('')}) }}
</div>

View File

@ -12,9 +12,7 @@
{% set counter = counter + 1 %}
{% embed 'core/card_top.html.twig' with {as: as, as_data: as_data, data: data, counter: counter, hours: hours|default('24 hours')} only %}
{% block rank %}
<div class="display-6 fw-bold rank">
# {{ counter }}
</div>
# {{ counter }}
{% endblock %}
{% block title %}

0
translations/.gitignore vendored Normal file
View File

View File

@ -0,0 +1,6 @@
hour: >-
{num_hours, plural,
=0 {# hour}
=1 {# hour}
other {# hours}
}

View File

@ -0,0 +1,6 @@
hour: >-
{num_hours, plural,
=0 {# heure}
=1 {# heure}
other {# heures}
}