Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- feat(panel): add `UserRbacRow` typed view-model so adapters render RBAC role and permission rows from a single normalized shape.
- feat: add shared UI parity contracts, Asset composition, and cross-adapter acceptance documentation.
- feat(ui): share database EXPLAIN markup across debugger adapters.
- feat(ui): add shared profiler normalization and Timeline rendering contracts.
12 changes: 12 additions & 0 deletions src/Panel/PanelRenderContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@
* @param array<array-key, mixed> $queryParams Parsed query parameters of the current debugger request.
* @param string $theme Resolved debugger theme.
* @param DebugUrlGeneratorInterface $urls Adapter-owned URL generator.
* @param array<string, array<string, mixed>> $panels Serialized panel payloads from the current snapshot.
*/
public function __construct(
public string $tag,
public string $panel,
public array $queryParams,
public string $theme,
private DebugUrlGeneratorInterface $urls,
private array $panels = [],
) {}

/**
Expand All @@ -47,6 +49,16 @@ public function historyUrl(array|null $queryParams = null): string
return $this->urls->history($queryParams ?? $this->queryParams);
}

/**
* Returns another panel payload from the current snapshot when cross-panel composition is required.
*
* @return array<string, mixed>|null Serialized panel payload, or `null` when it was not captured.
*/
public function panelPayload(string $panel): array|null
{
return $this->panels[$panel] ?? null;
}

/**
* Builds a panel URL for this captured request.
*
Expand Down
18 changes: 16 additions & 2 deletions src/Panel/Profile/ProfileCellRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ final class ProfileCellRenderer
/**
* Category prefix of the profiled blocks whose info is a raw SQL statement.
*/
private const string SQL_CATEGORY_PREFIX = 'yii\db\Command::';
private const array SQL_CATEGORY_PREFIXES = [
'yii\db\Command::',
'Yiisoft\Db\Command::',
];

/**
* Renders the category as the shared {@see Fqcn::renderLabel()} two-tone label: muted namespace prefix plus a
Expand Down Expand Up @@ -66,7 +69,7 @@ public static function renderInfoCell(ProfileRow $row): string
->content('→')
->render();

$body = str_starts_with($row->category, self::SQL_CATEGORY_PREFIX)
$body = self::isSqlCategory($row->category)
? Div::tag()->class('yii-debug-db-sql')
->html(SqlHighlighter::highlight($row->info))
->render()
Expand All @@ -92,4 +95,15 @@ public static function renderTimeCell(ProfileRow $row): string
->content(date('H:i:s.', (int) $seconds) . $suffix)
->render();
}

private static function isSqlCategory(string $category): bool
{
foreach (self::SQL_CATEGORY_PREFIXES as $prefix) {
if (str_starts_with($category, $prefix)) {
return true;
}
}

return false;
}
}
66 changes: 66 additions & 0 deletions src/Panel/Profile/ProfilingSnapshot.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use function array_map;
use function count;
use function is_array;
use function usort;

/**
* Canonical profiling snapshot holding the request metrics, the resolved profile blocks, and the memory samples that
Expand Down Expand Up @@ -67,6 +68,71 @@ public static function capture(int $memory, float $time, array $messages): self
return new self($memory, $time, $entries, $samples);
}

/**
* Normalizes completed profiler messages carrying token, category, timing, nesting, memory, and trace context.
*
* @param array<int|string, mixed> $messages Completed profiler messages.
*/
public static function captureCompleted(int $memory, float $time, array $messages): self
{
$entries = [];
$samples = [];

foreach ($messages as $message) {
if (!is_array($message)) {
continue;
}

$context = $message['context'] ?? null;

if (!is_array($context)) {
continue;
}

$beginTime = Coerce::floatOrNull($context['beginTime'] ?? $context['time'] ?? null);
$endTime = Coerce::floatOrNull($context['endTime'] ?? null);
$duration = Coerce::floatOrNull($context['duration'] ?? null);

if ($beginTime === null || $duration === null) {
continue;
}

$beginMemory = Coerce::intOrNull($context['beginMemory'] ?? null);
$endMemory = Coerce::intOrNull($context['endMemory'] ?? $context['memory'] ?? null);
$memoryDiff = Coerce::intOrNull($context['memoryDiff'] ?? null)
?? (($beginMemory !== null && $endMemory !== null) ? $endMemory - $beginMemory : 0);
$row = ProfileRow::fromTiming(
[
'timestamp' => $beginTime,
'duration' => $duration,
'category' => $context['category'] ?? $message['category'] ?? '',
'info' => $message['token'] ?? '',
'level' => $context['nestedLevel'] ?? 0,
'memory' => $endMemory ?? 0,
'memoryDiff' => $memoryDiff,
'trace' => $context['trace'] ?? [],
],
count($entries),
);

if ($row !== null) {
$entries[] = $row;
}

if ($beginMemory !== null) {
$samples[] = new MemorySample($beginTime * 1000, $beginMemory);
}

if ($endTime !== null && $endMemory !== null) {
$samples[] = new MemorySample($endTime * 1000, $endMemory);
}
}

usort($samples, static fn(MemorySample $a, MemorySample $b): int => $a->time <=> $b->time);

return new self($memory, $time, $entries, $samples);
}

/**
* @return list<ProfileRow> Resolved profile blocks in capture order.
*/
Expand Down
81 changes: 81 additions & 0 deletions src/Panel/Timeline/TimelineGeometry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

declare(strict_types=1);

namespace PHPForge\Debug\Panel\Timeline;

use PHPForge\Debug\Panel\Profile\ProfileRow;

use function floor;
use function log10;
use function max;
use function range;

/**
* Computes framework-neutral timeline ruler positions and profile-span geometry.
*/
final class TimelineGeometry
{
/**
* Returns adaptive ruler ticks keyed by milliseconds and valued by percentage offsets.
*
* @return array<int, float> Ruler offsets.
*/
public static function rulers(float $duration, int $line = 6): array
{
if ($line < 1 || $duration <= 0.0) {
return [];
}

$rough = $duration / $line;

$magnitude = 10 ** max(0, (int) floor(log10($rough)));

$normalized = $rough / $magnitude;

$step = match (true) {
$normalized <= 1.0 => $magnitude,
$normalized <= 2.0 => 2 * $magnitude,
$normalized <= 5.0 => 5 * $magnitude,
default => 10 * $magnitude,
};

$ticks = [0 => 0.0];
$limit = $duration - $step / 4;

if ($step > $limit) {
return $ticks;
}

foreach (range($step, $limit, $step) as $milliseconds) {
$ticks[(int) $milliseconds] = $milliseconds / $duration * 100;
}

return $ticks;
}
/**
* Converts captured profile rows into positioned timeline spans.
*
* @param list<ProfileRow> $rows Profile rows in capture order.
*
* @return list<TimelineSpanRow> Positioned spans in capture order.
*/
public static function spans(array $rows, float $start, float $duration): array
{
if ($duration <= 0.0) {
return [];
}

$spans = [];

foreach ($rows as $row) {
$spans[] = TimelineSpanRow::from(
$row,
($row->timestamp - $start) / $duration * 100,
$row->duration / $duration * 100,
);
}

return $spans;
}
}
139 changes: 139 additions & 0 deletions src/Panel/Timeline/TimelineMemoryRenderer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<?php

declare(strict_types=1);

namespace PHPForge\Debug\Panel\Timeline;

use PHPForge\Debug\Panel\MemorySample;
use UIAwesome\Html\Svg\{Defs, G, LinearGradient, Polygon, Polyline, Stop, Svg};

use function rtrim;
use function sprintf;
use function usort;

/**
* Renders profiler memory samples as the shared inline timeline SVG.
*/
final class TimelineMemoryRenderer
{
private const array GRADIENT = [
10 => 0.18,
60 => 0.45,
90 => 0.65,
100 => 0.85,
];

/**
* Renders an SVG memory graph, or `''` when its geometry cannot be resolved.
*
* @param list<MemorySample> $samples Memory samples in any order.
*/
public static function render(
array $samples,
float $start,
float $duration,
int $memory,
int $width = 1920,
int $height = 40,
): string {
if ($samples === [] || $duration <= 0.0 || $memory <= 0 || $width <= 0 || $height <= 0) {
return '';
}

$points = [];

foreach ($samples as $sample) {
$points[] = [
($sample->time - $start) / $duration * $width,
$height - ($sample->memory / $memory * $height),
];
}

usort($points, static fn(array $a, array $b): int => $a[0] <=> $b[0]);

return Svg::tag()
->height($height)
->html(
Defs::tag()->html(self::gradient()),
G::tag()->html(
Polygon::tag()
->points(self::polygonPoints($points, $width, $height))
->fill('url(#yii-debug-tl-memory-gradient)'),
Polyline::tag()
->points(self::polylinePoints($points, $width, $height))
->fill('none')
->stroke('currentColor')
->strokeWidth('1.5'),
),
)
->preserveAspectRatio('none')
->viewBox("0 0 {$width} {$height}")
->width($width)
->xmlns('http://www.w3.org/2000/svg')
->render();
}

private static function gradient(): LinearGradient
{
$stops = [];

foreach (self::GRADIENT as $percent => $opacity) {
$stops[] = Stop::tag()
->offset("{$percent}%")
->stopColor('currentColor')
->stopOpacity(self::number($opacity));
}

return LinearGradient::tag()
->id('yii-debug-tl-memory-gradient')
->x1(0)
->x2(0)
->y1(1)
->y2(0)
->html(...$stops);
}

private static function number(float|int $value): string
{
$rendered = rtrim(sprintf('%.6F', $value), '0');

return rtrim($rendered, '.');
}

/**
* @param list<array{0: float, 1: float}> $points
*/
private static function polygonPoints(array $points, int $width, int $height): string
{
$rendered = "0 {$height}";

$lastY = (float) $height;

foreach ($points as [$x, $y]) {
$rendered .= ' ' . self::number($x) . ' ' . self::number($y);
$lastY = $y;
}

return $rendered
. ' ' . self::number($width - 0.001) . ' ' . self::number($lastY)
. " {$width} {$height}";
}

/**
* @param list<array{0: float, 1: float}> $points
*/
private static function polylinePoints(array $points, int $width, int $height): string
{
$rendered = "0 {$height}";

$lastY = (float) $height;

foreach ($points as [$x, $y]) {
$rendered .= ' ' . self::number($x) . ' ' . self::number($y);

$lastY = $y;
}

return $rendered . " {$width} " . self::number($lastY);
}
}
Loading
Loading