Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# FroshTools - Claude Code Guidelines

## Datenbank-Aenderungen

**WICHTIG: Bei jeder Aenderung die die Datenbank betrifft, muss der Benutzer explizit darauf hingewiesen werden, BEVOR die Aenderung durchgefuehrt wird.**

Dazu gehoeren:
- Neue oder geaenderte Migrations (`src/Migration/`)
- Schema-Aenderungen (CREATE TABLE, ALTER TABLE, DROP TABLE, etc.)
- Daten-Manipulationen (INSERT, UPDATE, DELETE)
- Aenderungen an bestehenden Tabellen oder Spalten
- Aenderungen an Indizes oder Foreign Keys

Reine Lese-Operationen (SELECT, SHOW STATUS, information_schema Queries) sind davon ausgenommen und muessen nicht extra angekuendigt werden.

Der Benutzer muss die Datenbank-Aenderung ausdruecklich bestaetigen, bevor sie implementiert wird.
164 changes: 164 additions & 0 deletions src/Components/CacheStatisticsService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
<?php

declare(strict_types=1);

namespace Frosh\Tools\Components;

class CacheStatisticsService
{
public function __construct(private readonly CacheRegistry $cacheRegistry)
{
}

/**
* @return array{enabled: bool, hitRate: float, hits: int, misses: int, usedMemory: int, freeMemory: int, wastedMemory: int, wastedPercentage: float, cachedScripts: int, maxCachedScripts: int, internedStringsUsedMemory: int, internedStringsFreeMemory: int, lastRestart: string|null}|null
*/
public function getOpcacheStatistics(): ?array
{
if (!\function_exists('opcache_get_status')) {
return null;
}

$status = opcache_get_status(false);

if ($status === false) {
return null;
}

$config = \function_exists('opcache_get_configuration') ? opcache_get_configuration() : false;

$memory = $status['memory_usage'] ?? [];
$stats = $status['opcache_statistics'] ?? [];
$interned = $status['interned_strings_usage'] ?? [];

$hits = (int) ($stats['hits'] ?? 0);
$misses = (int) ($stats['misses'] ?? 0);
$total = $hits + $misses;

$maxScripts = 0;
if ($config !== false) {
$maxScripts = (int) ($config['directives']['opcache.max_accelerated_files'] ?? 0);
}

$lastRestart = null;
if (isset($stats['last_restart_time']) && $stats['last_restart_time'] > 0) {
$lastRestart = (new \DateTimeImmutable('@' . $stats['last_restart_time']))->format('c');
}

return [
'enabled' => (bool) ($status['opcache_enabled'] ?? false),
'hitRate' => $total > 0 ? round($hits / $total * 100, 2) : 0.0,
'hits' => $hits,
'misses' => $misses,
'usedMemory' => (int) ($memory['used_memory'] ?? 0),
'freeMemory' => (int) ($memory['free_memory'] ?? 0),
'wastedMemory' => (int) ($memory['wasted_memory'] ?? 0),
'wastedPercentage' => round((float) ($memory['current_wasted_percentage'] ?? 0), 2),
'cachedScripts' => (int) ($stats['num_cached_scripts'] ?? 0),
'maxCachedScripts' => $maxScripts,
'internedStringsUsedMemory' => (int) ($interned['used_memory'] ?? 0),
'internedStringsFreeMemory' => (int) ($interned['free_memory'] ?? 0),
'lastRestart' => $lastRestart,
];
}

/**
* @return array{enabled: bool, hitRate: float, hits: int, misses: int, usedMemory: int, availableMemory: int, entries: int, fragmentation: float}|null
*/
public function getApcuStatistics(): ?array
{
if (!\function_exists('apcu_cache_info') || !\function_exists('apcu_sma_info')) {
return null;
}

$cacheInfo = apcu_cache_info(true);
$smaInfo = apcu_sma_info();

if ($cacheInfo === false || $smaInfo === false) {
return null;
}

$hits = (int) ($cacheInfo['num_hits'] ?? 0);
$misses = (int) ($cacheInfo['num_misses'] ?? 0);
$total = $hits + $misses;

$availableMemory = (int) ($smaInfo['avail_mem'] ?? 0);
$totalSegSize = (int) ($smaInfo['num_seg'] ?? 1) * (int) ($smaInfo['seg_size'] ?? 0);
$usedMemory = $totalSegSize - $availableMemory;

$fragmentation = 0.0;
if ($totalSegSize > 0) {
$fragmentation = round(($smaInfo['num_seg'] > 0 ? (1 - $availableMemory / $totalSegSize) : 0) * 100, 2);
}

return [
'enabled' => true,
'hitRate' => $total > 0 ? round($hits / $total * 100, 2) : 0.0,
'hits' => $hits,
'misses' => $misses,
'usedMemory' => max(0, $usedMemory),
'availableMemory' => $availableMemory,
'entries' => (int) ($cacheInfo['num_entries'] ?? 0),
'fragmentation' => $fragmentation,
];
}

/**
* @return list<array{name: string, version: string, uptime: int, hits: int, misses: int, hitRate: float, usedMemory: int, peakMemory: int, maxMemory: int, evictedKeys: int, expiredKeys: int, totalKeys: int, connectedClients: int, opsPerSec: int}>
*/
public function getRedisStatistics(): array
{
$result = [];
$seenConnections = [];

foreach ($this->cacheRegistry->all() as $name => $adapter) {
try {
$redis = $adapter->getRedisOrFail();
} catch (\RuntimeException) {
continue;
}

$connectionId = $redis->getHost() . ':' . $redis->getPort();
if (\in_array($connectionId, $seenConnections, true)) {
continue;
}
$seenConnections[] = $connectionId;

try {
$info = $redis->info();
} catch (\RedisException) {
continue;
}

$hits = (int) ($info['keyspace_hits'] ?? 0);
$misses = (int) ($info['keyspace_misses'] ?? 0);
$total = $hits + $misses;

$totalKeys = 0;
foreach ($info as $key => $value) {
if (\str_starts_with($key, 'db') && \is_string($value) && preg_match('/keys=(\d+)/', $value, $matches)) {
$totalKeys += (int) $matches[1];
}
}

$result[] = [
'name' => $name,
'version' => (string) ($info['redis_version'] ?? 'unknown'),
'uptime' => (int) ($info['uptime_in_seconds'] ?? 0),
'hits' => $hits,
'misses' => $misses,
'hitRate' => $total > 0 ? round($hits / $total * 100, 2) : 0.0,
'usedMemory' => (int) ($info['used_memory'] ?? 0),
'peakMemory' => (int) ($info['used_memory_peak'] ?? 0),
'maxMemory' => (int) ($info['maxmemory'] ?? 0),
'evictedKeys' => (int) ($info['evicted_keys'] ?? 0),
'expiredKeys' => (int) ($info['expired_keys'] ?? 0),
'totalKeys' => $totalKeys,
'connectedClients' => (int) ($info['connected_clients'] ?? 0),
'opsPerSec' => (int) ($info['instantaneous_ops_per_sec'] ?? 0),
];
}

return $result;
}
}
159 changes: 159 additions & 0 deletions src/Components/DatabaseStatisticsService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
<?php

declare(strict_types=1);

namespace Frosh\Tools\Components;

use Doctrine\DBAL\Connection;

class DatabaseStatisticsService
{
public function __construct(private readonly Connection $connection)
{
}

/**
* @return array{version: string, uptime: int, threads: int, questions: int, slowQueries: int, queriesPerSecond: float}
*/
public function getServerInfo(): array
{
$version = $this->connection->fetchOne('SELECT VERSION()');

$statusVars = $this->fetchGlobalStatusMap([
'Uptime',
'Threads_connected',
'Questions',
'Slow_queries',
'Queries',
]);

$uptime = (int) ($statusVars['Uptime'] ?? 0);
$questions = (int) ($statusVars['Questions'] ?? 0);

return [
'version' => \is_string($version) ? $version : 'unknown',
'uptime' => $uptime,
'threads' => (int) ($statusVars['Threads_connected'] ?? 0),
'questions' => $questions,
'slowQueries' => (int) ($statusVars['Slow_queries'] ?? 0),
'queriesPerSecond' => $uptime > 0 ? round($questions / $uptime, 2) : 0.0,
];
}

/**
* @return list<array{name: string, engine: string|null, rows: int, dataSize: int, indexSize: int, totalSize: int}>
*/
public function getTableStatistics(): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT TABLE_NAME, ENGINE, TABLE_ROWS, DATA_LENGTH, INDEX_LENGTH
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
ORDER BY (DATA_LENGTH + INDEX_LENGTH) DESC'
);

$result = [];

foreach ($rows as $row) {
$dataSize = (int) ($row['DATA_LENGTH'] ?? 0);
$indexSize = (int) ($row['INDEX_LENGTH'] ?? 0);

$result[] = [
'name' => (string) ($row['TABLE_NAME'] ?? ''),
'engine' => $row['ENGINE'] !== null ? (string) $row['ENGINE'] : null,
'rows' => (int) ($row['TABLE_ROWS'] ?? 0),
'dataSize' => $dataSize,
'indexSize' => $indexSize,
'totalSize' => $dataSize + $indexSize,
];
}

return $result;
}

/**
* @return array{bufferPoolSize: int, bufferPoolUsed: int, bufferPoolHitRate: float, threadsConnected: int, threadsRunning: int, slowQueries: int, tmpDiskTables: int, tmpTables: int}
*/
public function getGlobalStatus(): array
{
$statusVars = $this->fetchGlobalStatusMap([
'Innodb_buffer_pool_read_requests',
'Innodb_buffer_pool_reads',
'Innodb_buffer_pool_pages_total',
'Innodb_buffer_pool_pages_free',
'Threads_connected',
'Threads_running',
'Slow_queries',
'Created_tmp_disk_tables',
'Created_tmp_tables',
]);

$variables = $this->fetchGlobalVariableMap([
'innodb_buffer_pool_size',
]);

$bufferPoolSize = (int) ($variables['innodb_buffer_pool_size'] ?? 0);
$pagesTotal = (int) ($statusVars['Innodb_buffer_pool_pages_total'] ?? 0);
$pagesFree = (int) ($statusVars['Innodb_buffer_pool_pages_free'] ?? 0);
$bufferPoolUsed = $pagesTotal > 0 && $bufferPoolSize > 0
? (int) (($pagesTotal - $pagesFree) / $pagesTotal * $bufferPoolSize)
: 0;

$readRequests = (int) ($statusVars['Innodb_buffer_pool_read_requests'] ?? 0);
$diskReads = (int) ($statusVars['Innodb_buffer_pool_reads'] ?? 0);
$hitRate = $readRequests > 0
? round(($readRequests - $diskReads) / $readRequests * 100, 2)
: 0.0;

return [
'bufferPoolSize' => $bufferPoolSize,
'bufferPoolUsed' => $bufferPoolUsed,
'bufferPoolHitRate' => $hitRate,
'threadsConnected' => (int) ($statusVars['Threads_connected'] ?? 0),
'threadsRunning' => (int) ($statusVars['Threads_running'] ?? 0),
'slowQueries' => (int) ($statusVars['Slow_queries'] ?? 0),
'tmpDiskTables' => (int) ($statusVars['Created_tmp_disk_tables'] ?? 0),
'tmpTables' => (int) ($statusVars['Created_tmp_tables'] ?? 0),
];
}

/**
* @param list<string> $names
* @return array<string, string>
*/
private function fetchGlobalStatusMap(array $names): array
{
$rows = $this->connection->fetchAllAssociative(
'SHOW GLOBAL STATUS WHERE Variable_name IN (?)',
[$names],
[\Doctrine\DBAL\ArrayParameterType::STRING]
);

$map = [];
foreach ($rows as $row) {
$map[(string) $row['Variable_name']] = (string) $row['Value'];
}

return $map;
}

/**
* @param list<string> $names
* @return array<string, string>
*/
private function fetchGlobalVariableMap(array $names): array
{
$rows = $this->connection->fetchAllAssociative(
'SHOW GLOBAL VARIABLES WHERE Variable_name IN (?)',
[$names],
[\Doctrine\DBAL\ArrayParameterType::STRING]
);

$map = [];
foreach ($rows as $row) {
$map[(string) $row['Variable_name']] = (string) $row['Value'];
}

return $map;
}
}
41 changes: 41 additions & 0 deletions src/Controller/StatisticsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace Frosh\Tools\Controller;

use Frosh\Tools\Components\CacheStatisticsService;
use Frosh\Tools\Components\DatabaseStatisticsService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;

#[Route(path: '/api/_action/frosh-tools/statistics', defaults: ['_routeScope' => ['api'], '_acl' => ['frosh_tools:read']])]
class StatisticsController extends AbstractController
{
public function __construct(
private readonly CacheStatisticsService $cacheStatisticsService,
private readonly DatabaseStatisticsService $databaseStatisticsService,
) {
}

#[Route(path: '/cache', name: 'api.frosh.tools.statistics.cache', methods: ['GET'])]
public function cacheStatistics(): JsonResponse
{
return new JsonResponse([
'opcache' => $this->cacheStatisticsService->getOpcacheStatistics(),
'apcu' => $this->cacheStatisticsService->getApcuStatistics(),
'redis' => $this->cacheStatisticsService->getRedisStatistics(),
]);
}

#[Route(path: '/database', name: 'api.frosh.tools.statistics.database', methods: ['GET'])]
public function databaseStatistics(): JsonResponse
{
return new JsonResponse([
'server' => $this->databaseStatisticsService->getServerInfo(),
'tables' => $this->databaseStatisticsService->getTableStatistics(),
'globalStatus' => $this->databaseStatisticsService->getGlobalStatus(),
]);
}
}
Loading