diff --git a/CHANGELOG.md b/CHANGELOG.md
index 054ea778..ffbdc3b8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,80 @@ All notable changes to LaraFly are documented here. This project uses CalVer (`Y
## [Unreleased]
+## [26.09.10] - 2026-09-29
+
+### Fixed
+
+- **OpenAPI contracts:** derive path-pattern `404` responses and restrict inferred validation `422` responses
+ to typed request bodies. Included page controllers now preserve their actual JSON, HTML or redirect return
+ contract. Browser coverage checks every generated operation, tag, response and component, including nested
+ schemas and keyboard access on phones.
+- **OpenAPI readability:** improve local Swagger text contrast for method and version badges, links, actions,
+ code examples, schema controls and constraints; verify expanded schemas with either system color preference.
+ Normalize native schema buttons and wrap operation controls to prevent phone overflow in WebKit.
+- **Error pages:** shorten paths in symlinked deployments and test harnesses, bound the debug stack before
+ rendering, and group dependency frames behind a native disclosure. Frame summaries stay on one line on desktop and give calls a second line on phones;
+ text contrast meets 4.5:1 in both themes. The production facts grid has no empty colored cells and shows
+ the reference once, with selectable text and an optional clipboard enhancement.
+- **Error URL safety:** validate configured home, sign-in, support and problem-type URLs at construction;
+ reject unsafe schemes, authority-relative paths and interior URL control characters.
+- **Problem documents:** substitute invalid UTF-8 and return a degraded document if encoding or a serialization
+ callback fails. Wildcard, absent and unsupported Accept types receive problem+json while error-page
+ fallback is enabled. Laravel's own validation, authentication and carried-response exceptions retain
+ their native handling.
+- **Migration:** problem `instance` values now begin with `/`, with unsafe path characters percent-encoded.
+ Clients comparing the old relative path must account for the leading slash.
+
+- **Admin listings:** replace competing column rules with typed columns, fixed table layout and explicit
+ colgroups. Route paths no longer collapse into stacks of characters beside unused space. Rigid column
+ widths include cell padding and allow for Linux header-font metrics, and a bounded table scrollport makes
+ sticky headers work.
+- **Stable paging:** append an ascending identity tiebreak so tied rows cannot move between pages. Clamp
+ stale out-of-range pages to the last page; a SQL-backed data listing may need one additional query.
+
+### Added
+
+- **Route detail:** permalinked, server-rendered route contracts with ordered caller/injected bindings,
+ resolver claims, binding-specific failures, bounded DTO trees, sibling comparison and duplicate warnings.
+ Gated wiring/configuration/API/traffic links, default responses, exception handlers, registered route
+ metadata and collapsed advice provenance make the compiled contract inspectable without running it.
+ `firefly.admin.routes.detail` and `firefly.admin.routes.advice` control the new surface.
+- **Bean explorer:** server-rendered landing/search/focus/module states, native keyboard links, bounded hop
+ columns, exact overflow links, complete paginated catalogue and relations, module coupling metrics,
+ conditions and shortest entry-point chains. Iterative SCC analysis handles deep graphs and self-cycles.
+- **Bean graph truthfulness:** stable competing factory identities across configurations, explicit unresolved
+ ambiguity instead of an arbitrary target, unknown factory scope and exclusion of unbound config DTOs.
+- **Explorer settings:** focus depth/row/node/path/page budgets, starter/module budgets and catalogue page size.
+ The legacy `firefly.admin.graph.max-nodes` is still parsed but no longer controls drawing; **0 no longer
+ forces a list**. Use the catalogue or relation tables for tabular exploration.
+
+- **Error navigation:** configured sign-in on 401, retry on GET/HEAD 5xx, and home/support links where
+ configured. Production ledes retain safe authored details and 405 pages name the allowed methods.
+- **Error configuration:** documented `max-frames`, `home`, `sign-in`, `support`, `actions`, `copy-button`,
+ `authored-detail`, `problem-fallback` and `problem.type-uri` settings in the reference and module guide.
+- **RFC 9457 type:** `about:blank` by default, a code-derived URI when an HTTP(S) base is configured, or an
+ omitted member when the setting is empty. Omitting `type` does not revert the corrected `instance` path.
+
+- **Shared listing controls:** server-side paging, sorting and searching with validated, bookmarkable URL
+ state across routes, beans, conditions, scheduled tasks, configuration, runtime and data listings. Paired
+ listings preserve each other's state, and row-count controls have submit buttons for use without JavaScript.
+- **Table settings:** `firefly.admin.table.page-size`, `page-sizes`, `max-page-size`, `max-height`, `density`
+ and `remember-scroll`. The offered size set is closed; the data browser applies its own bounds in series.
+ Auto-refresh keeps URL state; optional per-URL scroll restoration applies on reload and back/forward.
+
+### Changed
+
+- **`packages/admin` — the data browser's rows-per-page control offers the dashboard's set, and a `?size=`
+ outside it is refused rather than lowered.** `/firefly/data` used to draw its own `
diff --git a/book/src-es/04-first-http-api.md b/book/src-es/04-first-http-api.md
index 82213142..6b063aa8 100644
--- a/book/src-es/04-first-http-api.md
+++ b/book/src-es/04-first-http-api.md
@@ -421,19 +421,25 @@ final class ProblemDetailsRenderer
{
// An absent settings object means the SAFE answer, not the open one — see the constructor.
$disclose = $this->settings instanceof ErrorPageSettings && $this->settings->disclose;
+ $typeUri = $this->settings instanceof ErrorPageSettings ? $this->settings->typeUri : ProblemType::BLANK;
$correlationId = CorrelationIdFilter::of($request);
$reference = TraceContext::referenceFor($request);
$exception = ProblemMapper::toFireflyException($e, $disclose, $reference);
// …
- $payload = ErrorResponse::fromException(
+ $problem = ErrorResponse::fromException(
$exception,
- instance: $request->path(),
+ // …
+ instance: ProblemMapper::instanceFor($request),
traceId: $reference,
timestamp: (new DateTimeImmutable)->format(DateTimeInterface::ATOM),
correlationId: $correlationId,
- )->toArray();
+ // …
+ type: ProblemType::of($exception->errorCode(), $typeUri),
+ );
+ // …
+ $payload = $problem->toArray();
$headers = [
'Content-Type' => 'application/problem+json',
@@ -448,11 +454,13 @@ final class ProblemDetailsRenderer
// …
return new Response(
- json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES),
+ $this->encode($payload, $reference),
$exception->httpStatus(),
$headers,
);
}
+
+ // …
}
```
@@ -460,6 +468,10 @@ final class ProblemDetailsRenderer
El corte que hay encima del `return` oculta el resto del trabajo con cabeceras: las cabeceras que una `HttpExceptionInterface` ya trae se copian sobre la respuesta — el `Allow` de un `405`, entre ellas — y un `503` gana además `Retry-After: 5`.
+`$this->encode()` es el cuerpo, y es total a propósito. Un byte que no es UTF-8 válido — el mensaje de un driver que cita una columna latin-1, una cabecera de la petición copiada a un miembro de extensión — se sustituye en vez de elevarse. Cualquier cosa que `json_encode` siga rechazando, como un `INF` que una aplicación puso en una extensión en el punto del throw, cae a un documento mínimo; y también cae ahí cualquier cosa que lance el código *propio* de un objeto codificado, porque codificar un objeto llama a ese código — un `jsonSerialize()`, un accesor de Eloquent — y lo que lance no llega a ser una `JsonException` siquiera, razón por la cual el respaldo captura `Throwable` y no sólo la de JSON. Ese documento conserva todos los miembros que la forma de arriba define — el estado, el título, el código, la categoría, la severidad, la frase y los identificadores de referencia — porque cada uno es una cadena que escribió esta clase y ninguno puede ser lo que `json_encode` rechazó. Conserva también los errores de campo, con su nombre y su frase intactos — un `422` que siguiera diciendo `category: validation` sin llevar miembro `errors` mandaría a un cliente generado a la rama de errores de campo sin nada que pintar — y conserva el *nombre* de cada miembro de extensión: un valor que no puede llevar se escribe como el nombre de su tipo (`"balance": "App\Models\Balance"`) en vez de borrarse, que es como el endpoint de configuración de `firefly/actuator` lleva respondiendo siempre a esta misma pregunta. Sólo se pierden dos cosas: el valor ilegible, sustituido por su tipo, y el `rejectedValue` de cada error de campo, que se descarta del todo porque el contrato entero de ese miembro es *esto es lo que enviaste* y la palabra `float` no la envió nadie.
+
+Y la degradación queda registrada. El throwable que capturó el respaldo es una excepción cualquiera de la aplicación, así que se registra — por el `Psr\Log\LoggerInterface` opcional que el provider le pasa a este renderizador, con el `traceId` del propio documento en el contexto para que la línea de log y el cuerpo que tiene quien llama se puedan juntar, y dentro de su propio `try`/`catch` para que un canal de log que también esté caído no pueda lanzar fuera del manejador de errores. Un subsistema de manejo de errores que falla de forma invisible es justo lo que toda esta superficie existe para eliminar, y un renderizador que se tragara una excepción en silencio mientras publica un documento indistinguible de uno sano sería exactamente eso. Un renderizador que lanzara aquí, por su parte, fallaría mientras atiende el fallo, y quien llama no recibiría documento alguno.
+
Lo que `render()` deliberadamente **no** decide es en qué `FireflyException` se convierte un throwable cualquiera. Esa regla vive una clase más allá, en `Firefly\Web\Error\ProblemMapper`, porque la página de error HTML del Capítulo 10 necesita la respuesta idéntica y dos copias de ella acabarían dándole a un navegador y a un cliente de API códigos distintos para el mismo fallo:
@@ -494,7 +506,7 @@ Cinco brazos que cubren cuatro casos — el `405` tiene un brazo propio solo par
Una `FireflyException` — o una de sus subclases tipadas, como `ResourceNotFoundException` — se devuelve intacta y se renderiza con su propio `httpStatus()`, porque su mensaje lo escribió tu aplicación *para* el cliente; ese es justamente el sentido de la taxonomía. El propio límite de tiempo de ejecución de PHP se nombra aparte y responde `503` con una cabecera `Retry-After`, porque «el servidor detuvo esta petición a los N segundos» es algo sobre lo que quien llama puede actuar y un `500` desnudo no lo es. Una excepción HTTP de Laravel/Symfony — una URL que no coincide con ninguna ruta, un verbo que una ruta no acepta — conserva su **código de estado real**, así que una ruta no coincidente sigue respondiendo `404` y nunca un `500` engañoso; solo se reemplaza la redacción del propio enrutador por una frase escrita para una persona, y los verbos permitidos de un `405` pasan a un miembro `allowed` y a la cabecera `Allow`, donde un cliente puede leerlos sin analizar inglés. Cualquier otra cosa es un accidente, y `$disclose` — `firefly.web.problem.disclose`, por defecto `false` — decide si su mensaje puede publicarse siquiera: con él apagado el cuerpo lleva una frase fija que nombra la misma referencia que lleva el `traceId` del documento, y el mensaje real se queda en el log, que es donde el SQL de una `QueryException` y sus enlaces deben estar.
-Pedir un monedero que nunca se abrió se renderiza así. Fíjate en `instance`: es `$request->path()`, que Laravel devuelve **sin** barra inicial, así que es `api/v1/wallets/wlt-999` y no `/api/v1/wallets/wlt-999` — una cosa pequeña, y exactamente el tipo de cosa pequeña que un cliente que compara cadenas hace mal.
+Pedir un monedero que nunca se abrió se renderiza así. Fíjate en `instance`: el RFC 9457 §3.1.5 lo define como una **referencia** URI, y una referencia relativa se resuelve contra la URI base del documento — así que el `api/v1/wallets/wlt-999` pelado que devuelve `$request->path()`, servido desde `/api/v1/wallets/wlt-999`, identificaría `/api/v1/api/v1/wallets/wlt-999`. LaraFly publica la forma relativa a la raíz, que es el único trabajo de `ProblemMapper::instanceFor()`, y un cliente puede compararla con la ruta que pidió. Fíjate también en `type`: el RFC 9457 §3.1.1 dice que un `type` ausente *es* `about:blank`, y LaraFly lo escribe en vez de dejar que el lector tenga que saberlo — apunta `firefly.web.problem.type-uri` a una URI base y el `code` estable deriva uno real y abrible.
```json
{
@@ -504,7 +516,8 @@ Pedir un monedero que nunca se abrió se renderiza así. Fíjate en `instance`:
"category": "business",
"severity": "warning",
"detail": "Wallet wlt-999 not found",
- "instance": "api/v1/wallets/wlt-999",
+ "type": "about:blank",
+ "instance": "/api/v1/wallets/wlt-999",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"correlationId": "0f7c9b2e-6b43-4f5e-9a1d-2c8e5f0a91b7",
"timestamp": "2026-06-07T10:30:00+00:00"
@@ -521,7 +534,8 @@ Un fallo de comprobación `#[Valid]` en `POST /api/v1/wallets` — un `owner_id`
"category": "validation",
"severity": "warning",
"detail": "Validation failed",
- "instance": "api/v1/wallets",
+ "type": "about:blank",
+ "instance": "/api/v1/wallets",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"correlationId": "0f7c9b2e-6b43-4f5e-9a1d-2c8e5f0a91b7",
"timestamp": "2026-06-07T10:30:00+00:00",
@@ -543,7 +557,8 @@ Un intento de retiro se rechaza por dos vías distintas, y las dos **no dan el m
"category": "security",
"severity": "warning",
"detail": "Processing command [Lumen\\Application\\Command\\Withdraw] failed: Authentication is required.",
- "instance": "api/v1/wallets/wlt-1/withdraw",
+ "type": "about:blank",
+ "instance": "/api/v1/wallets/wlt-1/withdraw",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"correlationId": "0f7c9b2e-6b43-4f5e-9a1d-2c8e5f0a91b7",
"timestamp": "2026-06-07T10:30:00+00:00"
@@ -560,7 +575,8 @@ El `403` queda reservado para un principal que **sí** ha iniciado sesión y aun
"category": "security",
"severity": "warning",
"detail": "Processing command [Lumen\\Application\\Command\\Withdraw] failed: You do not have permission to do this.",
- "instance": "api/v1/wallets/wlt-1/withdraw",
+ "type": "about:blank",
+ "instance": "/api/v1/wallets/wlt-1/withdraw",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"correlationId": "0f7c9b2e-6b43-4f5e-9a1d-2c8e5f0a91b7",
"timestamp": "2026-06-07T10:30:00+00:00",
diff --git a/book/src-es/04a-openapi.md b/book/src-es/04a-openapi.md
index b759e6d2..44e91712 100644
--- a/book/src-es/04a-openapi.md
+++ b/book/src-es/04a-openapi.md
@@ -141,6 +141,7 @@ Dentro de una operación, el plan de enlace hace el trabajo de verdad. `Operatio
$files = [];
$validated = false;
$rejectable = false;
+ $notFound = false;
foreach ($route->bindings as $binding) {
// Claimed by a resolver: bound from somewhere other than the request, so neither a parameter nor
@@ -149,12 +150,11 @@ Dentro de una operación, el plan de enlace hace el trabajo de verdad. `Operatio
continue;
}
- $validated = $validated || $binding['valid'];
-
switch ($binding['kind']) {
case 'path':
$parameters[] = $this->parameter($binding, 'path', true, $doc->parameters[$binding['key']] ?? null);
$rejectable = $rejectable || $this->coercible($binding);
+ $notFound = $notFound || isset($binding['pattern']);
break;
case 'query':
$parameters[] = $this->parameter($binding, 'query', $binding['required'], $doc->parameters[$binding['key']] ?? null);
@@ -171,6 +171,7 @@ Dentro de una operación, el plan de enlace hace el trabajo de verdad. `Operatio
case 'body':
$body = $binding;
$rejectable = true;
+ $validated = $validated || ($binding['valid'] && $binding['type'] !== null);
break;
}
}
@@ -185,7 +186,7 @@ Dentro de una operación, el plan de enlace hace el trabajo de verdad. `Operatio
$operation['requestBody'] = $this->multipartBody($files);
}
- $operation['responses'] = $this->responseSet($route, $rejectable, $validated, $doc, $registry);
+ $operation['responses'] = $this->responseSet($route, $rejectable, $validated, $notFound, $doc, $registry);
// …
return $operation;
}
@@ -425,7 +426,7 @@ final class ProblemSchema
}
```
-La distinción importa porque las dos formas difieren. El `ErrorResponse::toArray()` del Capítulo 4 emite `status`, `title`, `code`, `category` y `severity` incondicionalmente, luego `detail`/`type`/`instance`/`traceId`/`timestamp` solo cuando no son nulos, y luego `errors` solo cuando no está vacío. Así que `code`, `category`, `severity` y `errors` son miembros de Firefly encima de los cinco de la RFC; `type` es opcional aquí, donde la RFC le da un valor por defecto; e `instance` lleva una *ruta* de petición y no una referencia URI. Documentar la forma de la RFC en vez de esta le entregaría a cada cliente generado un decodificador que descarta en silencio los tres miembros sobre los que un llamante realmente ramifica.
+La distinción importa porque las dos formas difieren. El `ErrorResponse::toArray()` del Capítulo 4 emite `status`, `title`, `code`, `category` y `severity` incondicionalmente, luego `detail`/`type`/`instance`/`traceId`/`timestamp` solo cuando no son nulos, y luego `errors` solo cuando no está vacío. Así que `code`, `category`, `severity` y `errors` son miembros de Firefly encima de los cinco de la RFC; `type` se escribe en todos los documentos como `about:blank` — el RFC 9457 §3.1.1 dice que un `type` ausente *es* `about:blank`, y LaraFly toma la decisión del `ProblemDetail` de Spring y lo dice, mientras que `firefly.web.problem.type-uri` o bien apunta a una URI base de la que derivar un tipo real a partir del `code` estable, o bien se pone a `''` para omitir el miembro; e `instance` es una **referencia URI relativa a la raíz**, construida por `ProblemMapper::instanceFor()`, y no la *ruta* de petición pelada que llevaba hasta la pasada del RFC 9457. Documentar la forma de la RFC en vez de esta le entregaría a cada cliente generado un decodificador que descarta en silencio los tres miembros sobre los que un llamante realmente ramifica.
Las dos enumeraciones se leen directamente de los propios enums del kernel, de modo que un caso añadido en `firefly/kernel` aparece en la especificación en la siguiente generación sin ninguna edición en `firefly/openapi`:
@@ -462,7 +463,7 @@ Qué estados enumera una operación es algo **derivado, no adivinado**. Compara
}
```
-El `400` aparece exactamente cuando la operación tiene algo que `ArgumentResolver` pueda rechazar *antes* de que corra el controlador — un cuerpo que decodificar y enlazar, una subida que validar, una query o cabecera obligatoria que el cliente puede omitir, o un parámetro no-`string` que hay que coercer desde la cadena del cable. Está deliberadamente ausente de `balance`: nada de esa petición puede fallar el enlace, porque un segmento de ruta ausente no casa con la ruta en absoluto, y un `400` documentado que el endpoint no puede producir es ruido que un cliente generado convierte en una rama de error muerta. El `422` aparece exactamente cuando algún enlace lleva `#[Valid]`, porque esa es la única forma de que `BeanValidator` corra y por tanto la única forma de que se lance la `ValidationException` del Capítulo 4. Y `default` cubre todo lo que el propio manejador pueda levantar — un `404` de una `ResourceNotFoundException`, un `409` de una `ConflictException`, un `403` de un `#[PreAuthorize]` denegado — que no puede enumerarse desde el manifiesto de rutas sin leer el cuerpo del controlador, y que de todos modos se renderiza todo a través del mismo `ProblemDetailsRenderer`.
+El `400` aparece exactamente cuando la operación tiene algo que `ArgumentResolver` pueda rechazar *antes* de que corra el controlador — un cuerpo que decodificar y enlazar, una subida que validar, una query o cabecera obligatoria que el cliente puede omitir, o un parámetro no-`string` que hay que coercer desde la cadena del cable. Está deliberadamente ausente de `balance`: nada de esa petición puede fallar el enlace, porque un segmento de ruta ausente no casa con la ruta en absoluto, y un `400` documentado que el endpoint no puede producir es ruido que un cliente generado convierte en una rama de error muerta. El `404` se deriva de un parámetro de ruta con patrón. El `422` se deriva de cuerpos tipados con `#[Valid]`, siguiendo la rama que llama a `BeanValidator`; los parámetros de consulta, servicios y cuerpos sin tipo no la activan. Y `default` cubre todo lo que el propio manejador pueda levantar — un `404` de una `ResourceNotFoundException`, un `409` de una `ConflictException`, un `403` de un `#[PreAuthorize]` denegado — que no puede enumerarse desde el manifiesto de rutas sin leer el cuerpo del controlador, y que de todos modos se renderiza todo a través del mismo `ProblemDetailsRenderer`.
Un `204`, o un retorno `void`/`never`, no obtiene contenido alguno, porque emitir un mapa de contenido para un estado que no lleva cuerpo es exactamente lo que un generador de clientes estricto convierte en un tipo de retorno fantasma. El cuerpo de éxito de todo lo demás es el asunto de la siguiente sección.
@@ -644,7 +645,7 @@ El Capítulo 4 presentó `#[RestController]` junto a su hermano HTML `#[Controll
Lee el nombre antes que el cuerpo: `excluded()` responde *deja esta ruta fuera*, así que cada `return true` de arriba es una ruta que **no** llega al documento. El primer brazo es la regla de `#[Controller]`, el segundo es `#[ApiIgnore]`, y el tercero es la lista configurada de prefijos de ruta.
-Una ruta `#[Controller]` renderiza una página. Forma parte de la superficie HTTP de la aplicación, pero no es una operación JSON, y describirla como `application/json` haría que un generador emitiera un cliente tipado para una respuesta que es una página web — la propia página de bienvenida del framework estaba en la especificación exactamente así antes de que existiera esta regla. Pon `firefly.openapi.include-html` a `true` y la ruta se documenta igualmente, pero con honestidad: la operación se produce entonces con contenido `text/html` y un esquema `type: string`, no con un esquema JSON que sería una mentira sobre la que un generador de clientes actuaría fielmente.
+Las rutas `#[Controller]` quedan excluidas por defecto. Activa `firefly.openapi.include-html` para incluirlas. El medio de respuesta se deriva del retorno: las vistas y los valores `Htmlable` producen `text/html`, los arrays producen JSON y las redirecciones llevan `Location`. El estereotipo no determina el medio de respuesta.
La segunda mitad de ese método es el instrumento romo para todo lo demás: `firefly.openapi.exclude` es un CSV de prefijos de ruta — `'/internal,/admin'` — para rutas que son JSON pero no son la API pública de nadie.
@@ -775,7 +776,7 @@ return [
'description' => '',
'servers' => ['https://api.example.test'], // bare URLs or OpenAPI Server Objects
'exclude' => '/internal,/admin', // CSV of path prefixes to leave out
- 'include-html' => false, // document #[Controller] routes as text/html
+ 'include-html' => false, // include #[Controller] routes with their return contract
],
];
```
@@ -849,11 +850,11 @@ final class ApiDocsConfiguration
| `ConstraintSchemaMapper` | Mapea la lista de reglas compilada — no los atributos — a palabras clave; gana quien escribe primero, así que `#[Min(1)] int` se queda en `integer` |
| `x-firefly-constraints` | Registra lo que JSON Schema no sabe enunciar (`after:now`, un dígito de control, un PCRE con banderas, una regla de terceros) en lugar de descartarlo |
| `ProblemSchema` | La única respuesta compartida `application/problem+json`; documenta `code`/`category`/`severity`/`errors` de Firefly, con los enums leídos de los propios casos del kernel |
-| Conjunto de errores derivado | `400` solo cuando algo es rechazable antes de que corra el controlador, `422` solo bajo `#[Valid]`, `default` siempre |
+| Conjunto de errores derivado | `400` solo cuando algo es rechazable antes de que corra el controlador, `404` para patrones de ruta, `422` para cuerpos tipados con `#[Valid]`, `default` siempre |
| `DocType` | Compila una expresión de tipo PHPDoc a un fragmento de JSON Schema — shapes, genéricos, tuplas, uniones de literales, pseudo-tipos de PHPStan — y devuelve *nada* en lugar de adivinar cuando no puede leer una |
| `ResponseSchemaFactory` | Construye una clase devuelta desde su forma de CABLE: el `@return` declarado de `jsonSerialize()` cuando lo hay, las propiedades públicas si no. Los miembros de respuesta siguen siendo `required` y los nullables ensanchan su tipo |
| `#[ApiResponse(type:)]` | Una expresión de tipo completa (`'list'`), resuelta con los propios imports del controlador |
-| `$route->html` | Las rutas HTML `#[Controller]` quedan excluidas por defecto; `firefly.openapi.include-html` las documenta como `text/html`, nunca como JSON |
+| `$route->html` | Las rutas `#[Controller]` quedan excluidas por defecto; `firefly.openapi.include-html` las incluye con el medio derivado de su retorno |
| `firefly.openapi.viewer.style` | `swagger` (por defecto) \| `builtin` \| `cdn`. Solo `cdn` hace una petición a un tercero en cada visita; un valor no reconocido cae de vuelta a `swagger` |
| `SwaggerAssets` | Sirve el Swagger UI OFICIAL desde tu propio origen, desde el paquete de composer `swagger-api/swagger-ui` — siete nombres de fichero en lista blanca, cada uno comprobado con `realpath()` dentro del directorio dist |
| `ViewerPage::render()` | Cae de vuelta a `builtin` cuando falta la dist de Swagger, en lugar de renderizar una consola cuyos assets dan 404 |
diff --git a/book/src-es/11-observability-actuator.md b/book/src-es/11-observability-actuator.md
index a4fea674..9a407d73 100644
--- a/book/src-es/11-observability-actuator.md
+++ b/book/src-es/11-observability-actuator.md
@@ -933,133 +933,21 @@ Un endpoint que lanza se captura y se reporta como `null` en vez de dejar que se
---
-### El grafo de beans
+### El explorador de beans
-La mayoría de las páginas son tablas. Dos dibujan una imagen — esta, y el [mapa de entidades](#recorrer-el-modelo-relaciones-filtros-y-un-mapa) más adelante en el capítulo — y esta es la que amortiza el paquete el día en que algo está mal cableado.
+`/firefly/graph` abre con búsqueda, puntos de entrada, beans con más dependientes, acoplamiento entre módulos y ciclos del cableado. `?bean=` muestra un entorno acotado; `?module=` muestra los beans del módulo y sus relaciones externas; `?q=` busca en el catálogo completo. `/firefly/beans` incluye componentes, productos de fábrica y DTOs de configuración enlazados, ordenados inicialmente por número de dependientes descendente.
-`/actuator/beans` te dice *qué* beans existen. No puede decirte a qué está **cableado** cada uno, que es lo que realmente quieres cuando un `#[ConditionalOnMissingBean]` no se disparó como esperabas, cuando un ciclo entre singletons ansiosos ha colgado un arranque sin mensaje alguno, o cuando intentas averiguar a qué se enganchó el paquete que acabas de instalar. `/firefly/graph` responde a eso, como un diagrama SVG por capas más una tabla de relaciones filtrable.
+El bean seleccionado queda entre sus dependientes a la izquierda y sus dependencias a la derecha. PHP calcula las posiciones: cajas de 176×38 píxeles, separadas cada 204×46 píxeles, con un máximo predeterminado de 16 filas por columna y 72 nodos. Dos saltos ocupan 992 píxeles de ancho y hasta 728 de alto. Las cabeceras se desplazan con las columnas. Los enlaces nativos, formularios GET, paginación y foco visible de teclado funcionan sin JavaScript; no hay cámara ni ajuste de escala. Texto y siglas identifican los módulos además del color decorativo.
-No se refleja nada para construirlo. `ComponentScanner` ya registra, en tiempo de **escaneo**, los tipos de clase e interfaz que pide el constructor de cada componente, y esa lista viaja en el manifiesto compilado igual que cualquier otro hecho escaneado (abreviado):
+La selección reparte cada columna por turnos entre los nodos de la frontera. Los enlaces de desbordamiento abren las tablas completas y paginadas de vecinos directos del origen, con interfaz y tipo de relación. Las cadenas más cortas desde puntos de entrada explican la alcanzabilidad. Se conservan todas las condiciones positivas y negativas, incluidas las de la configuración productora; la ausencia del endpoint Conditions se tolera.
-
-```php
-final readonly class ComponentDescriptor
-{
- // …
- public function __construct(
- public string $class,
- public string $stereotype,
- // …
- public array $interfaces,
- // …
- /**
- * The class types this component's constructor asks for — the edges of the bean graph.
- *
- * Recorded at scan time, where reflection is already sanctioned, because the alternative is
- * reflecting at request time to answer "what depends on what", which the reflection-free boot
- // …
- */
- public array $dependencies = [],
- ) {}
-// …
-}
-```
-
-Esa última frase es una decisión de diseño en la que merece la pena detenerse. Un parámetro de constructor tipado `string $name` es configuración; dibujarlo como una arista enterraría las relaciones que importan bajo ruido de `string`/`int`. Un parámetro de **clase anulable o con valor por defecto** *sí* se conserva, porque un colaborador opcional sigue siendo una relación.
-
-#### Tres clases de nodo, y por qué la primera versión estaba casi vacía
-
-Antes que las aristas, los nodos — porque la primera versión de esta página los entendió mal de una forma de la que merece la pena aprender. Una aplicación LaraFly tiene **tres clases de bean**, y las tres tienen que ser nodos:
-
-| Clase | Qué es | De dónde sale |
-|---|---|---|
-| `component` | Una clase escaneada `#[Component]`/`#[Service]`/`#[Repository]`/`#[RestController]`/`#[Configuration]` | El catálogo de beans |
-| `bean` | Un valor **producido por un método fábrica `#[Bean]`** de una `#[Configuration]` | Las filas `produces` del catálogo |
-| `config` | Un DTO `#[ConfigProperties]` enlazado desde la configuración | El endpoint `configprops` |
-
-Al principio solo la primera clase era un nodo, y la consecuencia no fue cosmética. El cableado de un framework vive casi por completo en la segunda clase: una autoconfiguración es una `#[Configuration]` cuyos métodos `#[Bean]` producen `MeterRegistry`, `TransactionTemplate`, `AggregateTracker` y demás. Con solo las clases declarantes como nodos, cada arista que apuntaba a uno de esos productos apuntaba a un nodo que no existía. Medido sobre un esqueleto de serie: **42 nodos, 41 productos `#[Bean]` ausentes, 21 dependencias colgando y exactamente una arista dibujada.** La página no mostraba un grafo disperso — era estructuralmente incapaz de mostrar el cableado del framework.
-
-La tercera clase es el mismo error en miniatura. Un DTO `#[ConfigProperties]` está enlazado y es inyectable, pero no se escanea como componente ni lo produce una fábrica, así que nada en el catálogo de beans puede verlo: `App\GreetingProperties` aparecía como *dependencia no resuelta* de `GreetingService` en lugar de como el bean que es. Por eso la página lee el endpoint `configprops` junto al catálogo.
-
-De modo que también hay dos clases de arista, y dicen cosas distintas:
-
-| Arista | De → a | Significado |
-|---|---|---|
-| `injects` | Un bean → algo de lo que declaró depender | El consumidor lo pidió; el contenedor lo satisface |
-| `produces` | Una `#[Configuration]` → el valor que devuelve uno de sus métodos `#[Bean]` | Esta clase es de donde sale ese bean |
-
-Las aristas `injects` se recogen de los parámetros del **constructor** de un componente *y* de los parámetros de cada **método fábrica `#[Bean]`** — el producto depende de lo que su fábrica pidiera. Esa unión es el cableado; los constructores por sí solos son una fracción de él.
-
-Una sutileza sobre la identidad. Un producto `#[Bean]` se identifica normalmente por el **tipo que produce**, porque esa es la clave que enlaza el contenedor y la clave que pide todo consumidor. Pero cuando dos métodos fábrica producen el mismo tipo — la forma que las reglas `#[Primary]`/`#[Qualifier]` del Capítulo 2 existen para desambiguar — el tipo por sí solo los colapsaría en un único nodo y ocultaría justo la ambigüedad por la que abriste la página. Así que cada competidor recibe `Declarante::metodo()` como identidad propia y el tipo desnudo resuelve al primero de ellos, reflejando al contenedor, donde la clave de tipo es un alias del ganador mientras todo candidato sigue alcanzable por nombre.
-
-#### Lo difícil no es dibujar, es resolver
-
-Un constructor pide un **tipo**, y ese tipo es muy a menudo una interfaz — `EventPublisher`, `HealthIndicator`, `Cache` — mientras que el bean que lo satisface es una clase concreta que meramente la implementa. Una lista de aristas construida ingenuamente a partir de los tipos del constructor apunta entonces a nodos que no existen, y el grafo sale como un campo de puntos desconectados. Pregúntate a qué debería dibujar una flecha la dependencia de `WalletService` sobre `WalletRepository`: no al puerto, que es una interfaz sin bean propio, sino a `EloquentWalletRepository`, que es lo que de verdad se va a construir.
-
-Así que cada dependencia se resuelve a través de un índice de interfaces antes de convertirse en arista:
-
-
-```php
-foreach ($entry['dependencies'] as $dependency) {
- $target = $this->resolve($dependency);
-
- if ($target === null) {
- $unresolved[] = $dependency;
-
- continue;
- }
- // …
- $edges[] = [
- 'from' => $entry['from'],
- 'to' => $target,
- 'via' => $target === $dependency ? null : $dependency,
- 'type' => $entry['type'],
- ];
-}
-```
-
-
-```php
-private function resolve(string $type): ?string
-{
- return isset($this->nodes[$type]) ? $type : ($this->satisfiedBy[$type] ?? null);
-}
-```
-
-El miembro `via` es la honestidad de ese bucle. Cuando la arista pasó por una interfaz, el diagrama la marca y la columna **Wired by** de la tabla de relaciones nombra la interfaz, de modo que quien lee ve la indirección en lugar de que se le muestre calladamente una relación que nunca escribió. Cuando el constructor nombró la clase concreta, la columna simplemente dice `class`.
-
-El índice se construye en orden de catálogo y **gana el primer implementador**, de forma determinista — el catálogo se emite en orden de escaneo, así que la misma aplicación dibuja siempre el mismo grafo en lugar de rebarajarse entre máquinas. Una interfaz con varios implementadores es una ambigüedad real que el contenedor resuelve con `#[Primary]`/`#[Qualifier]`, y el grafo lo dice listando la arista como `via` en lugar de fingir que la elección era obvia.
-
-#### Capas, ciclos y el techo de nodos
-
-Los niveles salen de un recorrido de **camino más largo** sobre las aristas resueltas: la profundidad de un nodo es uno más que la de lo más profundo de lo que depende, y después los niveles se invierten para que el nivel 0 contenga aquello de lo que nada depende. El efecto es que un nodo siempre queda por debajo de todo lo que depende de él, las flechas se leen consistentemente hacia abajo, y la vista puede seguir una cadena desde un controlador hasta el repositorio que hay al final. La vista solo posiciona; los niveles vienen del modelo.
-
-La profundidad se memoiza y el recorrido lleva su propio conjunto de visitados, así que un ciclo termina en lugar de recursar para siempre — y la arista que lo cerró se *reporta*:
-
-
-```php
-foreach ($out[$node] ?? [] as $next) {
- if (isset($path[$next])) {
- $cycles[] = ['from' => $node, 'to' => $next];
-
- continue;
- }
- $deepest = max($deepest, $walk($next, $path) + 1);
-}
-```
-
-Ese reporte vale más de lo que parece. El contenedor no tiene detección de ciclos propia, así que un ciclo entre singletons ansiosos no produce un error útil — agota la memoria en el arranque. Una página que nombra las dos clases implicadas convierte "la app murió sin mensaje" en un diagnóstico de cinco segundos, y el consejo del propio panel es el correcto: rompe una de estas aristas, normalmente inyectando una interfaz y dejando que el otro lado dependa de ella.
-
-Dos límites se declaran en la página en lugar de ocultarse:
+Cada relación entre módulos muestra peso (relaciones resueltas), beans destino distintos, interfaces distintas y relaciones concretas. Las aristas de producción se excluyen salvo petición explícita. Las listas siguen paginadas cuando se supera el presupuesto del mapa. El módulo señala la alcanzabilidad exclusiva y los beans sin dependientes; son observaciones del catálogo, no una garantía de que retirar un módulo sea seguro.
-* **Pasados los `firefly.admin.graph.max-nodes` — 220 por defecto — el diagrama se suprime** y la tabla de Relaciones de abajo lleva la misma información como una lista filtrable. Un diagrama de más de un par de centenares de nodos es una maraña, no algo que una persona pueda leer, y renderizarlo de todos modos sería peor respuesta que negarse. Es una clave de configuración y no una constante porque "ilegible" depende de la pantalla y de la aplicación.
-* **"Provided outside the container" no es una advertencia.** Esas etiquetas son tipos de constructor satisfechos por un binding del contenedor de Laravel y no por un bean escaneado — la `Request`, el repositorio de configuración, una conexión. Se listan en lugar de descartarse en silencio precisamente porque *"¿por qué no está mi bean en el grafo?"* es la pregunta que la página tiene que responder. Un tipo que aparezca ahí y que esperabas que fuera un bean *tuyo* significa que tu escaneo no lo vio, y `firefly.scan.paths` es lo primero que hay que revisar.
+Tarjan iterativo identifica componentes fuertemente conexos, incluida la autoinyección, sin recorridos recursivos. Un ciclo que incluye producción de fábricas no implica necesariamente un ciclo de constructores en ejecución. Las fábricas competidoras mantienen identidades separadas incluso en configuraciones diferentes. La proyección no aporta los metadatos primary/qualifier necesarios: los destinos ambiguos quedan sin resolver y el ámbito de fábrica se muestra como no informado. Se excluyen los DTOs explícitamente no enlazados. No se instancia ningún bean para inspeccionarlo.
-!!! tip "Léelo junto a la página de Condiciones"
- Las dos responden mitades complementarias de toda sorpresa de auto-configuración. **Condiciones** dice *si* un bean del framework se registró o se echó atrás, y sobre qué condición. **El grafo** dice a qué está cableado el bean que sí ganó, y a través de qué interfaz. Una arista `EventPublisher` apuntando a `InMemoryEventPublisher` cuando configuraste `firefly.eda.provider=rabbitmq` se ve de un vistazo en el grafo; Condiciones nombra entonces el `#[ConditionalOnProperty]` que no casó.
+**Migración:** `firefly.admin.graph.max-nodes` sigue leyéndose en `AdminSettings::$graphMaxNodes` (220 por defecto), pero ya no controla el dibujo. **0 ya no fuerza una lista**. Para una vista tabular utiliza el catálogo completo o las tablas de relaciones.
-!!! note "Lo que el grafo sigue sin decidir por ti"
- Dos límites merecen conocerse, y ninguno es una carencia de datos. **`#[Primary]`/`#[Qualifier]` no dirigen el índice** — gana quien escriba primero en orden de escaneo, tanto para una interfaz con varios implementadores como para la clave de tipo desnuda de un `#[Bean]` disputado. Cada competidor sigue teniendo su propio nodo y la arista se marca `via`, así que la ambigüedad es visible en la página, pero el destino dibujado puede no ser el que resuelve el contenedor. Y **un tipo no resuelto se reporta, nunca se explica**: la página puede decirte que un tipo lo provee algo fuera del contenedor, pero no *qué* enlace lo provee, porque un enlace del contenedor de Laravel no lleva descriptor que leer.
+Las claves nuevas son `firefly.admin.graph.focus.depth` (2, límites 1–4), `focus.max-rows` (16, 4–60), `focus.max-nodes` (72, 8–300), `focus.max-paths` (3, 0–10), `focus.page-size` (50, 10–500), `firefly.admin.graph.starters` (12, 1–50), `firefly.admin.graph.modules.max-nodes` (40, 0–200; 0 desactiva el mapa) y `firefly.admin.beans.page-size` (50, 10–500). Los tamaños de página también respetan los límites compartidos de tablas. Todas están documentadas en `skeleton/config/firefly.php`.
---
@@ -1233,7 +1121,7 @@ Es un *interruptor de funcionalidad*, no un endpoint de configuración remota: l
| `ObservabilityAutoConfiguration` `#[Order(500)]` | El mismo truco de precedencia que la costura de seguridad del Capítulo 10: registra `cqrsMetrics()` antes de que `CqrsAutoConfiguration` evalúe su `#[ConditionalOnMissingBean]` |
| `firefly/admin` | Un panel Blade renderizado en el servidor en `/firefly`; una cuyo endpoint no está registrado o está apagado se oculta del menú en lugar de enlazarse |
| `AdminEndpointReader` | Invoca cada `ActuatorEndpoint` **en proceso** desde el `ActuatorRegistry`, sorteando `ExposureModel` — así el panel muestra lo que la superficie HTTP no expone, y un endpoint que lanza degrada un solo panel |
-| `BeanGraph` | Convierte el catálogo de beans en un grafo de dependencias dibujado sobre **tres clases de nodo** — componentes, productos `#[Bean]` y DTOs `#[ConfigProperties]` — con aristas `injects`/`produces` resueltas a través de un índice de interfaces (marcadas `via`), estratificación por camino más largo, ciclos reportados en lugar de colgarse, y el diagrama suprimido pasados `firefly.admin.graph.max-nodes` (220) |
+| `BeanGraph` | Convierte el catálogo de beans en un grafo de dependencias dibujado sobre **tres clases de nodo** — componentes, productos `#[Bean]` y DTOs `#[ConfigProperties]` — con aristas `injects`/`produces` resueltas a través de un índice de interfaces (marcadas `via`), estratificación por camino más largo, ciclos reportados en lugar de colgarse, y exploración acotada por saltos con SCCs iterativos |
| Los productos `#[Bean]` como nodos | El cableado de un framework vive en métodos fábrica, no en constructores; con solo las clases declarantes como nodos, un esqueleto de serie dibujaba **una** arista de 42 beans |
| `ComponentDescriptor::$dependencies` | Las aristas del grafo, registradas por `ComponentScanner` en tiempo de **escaneo** — solo tipos de clase e interfaz, porque un parámetro escalar es configuración, no cableado |
| `firefly.admin.enabled` | Toma por defecto `app.debug`; un valor explícito gana en ambas direcciones, y encenderlo con debug apagado te obliga a poner tu propio middleware de autenticación delante de la ruta |
diff --git a/book/src-es/12-testing.md b/book/src-es/12-testing.md
index 7465fc78..d9a45b78 100644
--- a/book/src-es/12-testing.md
+++ b/book/src-es/12-testing.md
@@ -269,24 +269,26 @@ function assertNoEventsPublished(object $publisher): void
}
```
-!!! warning "Una limitación real y honesta: `toBeProblemDetails()` y el campo `type`"
- El propio `Web\WalletRestTest.php` de `samples/lumen` documenta, en un comentario de código justo al lado de las pruebas que afecta, una brecha genuina: `toBeProblemDetails()` afirma que el cuerpo de la respuesta tiene una clave `type`, pero el renderizador RFC-7807 real del framework (`Firefly\Kernel\Error\ErrorResponse::fromException()`, renderizado por `Firefly\Web\Exception\ProblemDetailsRenderer`) nunca rellena `type` — es un campo opcional que solo se emite cuando se pasa explícitamente, cosa que el camino de excepción-a-respuesta nunca hace. Llamar a `toBeProblemDetails()` contra un `404`/`422`/`403`/`409` genuino que este framework realmente produce falla, siempre, por exactamente esa razón.
+!!! note "`toBeProblemDetails()` y el campo `type`: una limitación real, y cómo se cerró"
+ Esta caja describía una brecha genuina, y vale la pena tenerla a la vista porque es la forma que suelen tener estas cosas. `toBeProblemDetails()` afirma que el cuerpo de la respuesta tiene una clave `type` — y el renderizador real del framework, `Firefly\Web\Exception\ProblemDetailsRenderer`, no publicaba ningún `type`. Era un miembro opcional que solo se emitía cuando algo lo pasaba explícitamente, cosa que el camino de excepción-a-respuesta nunca hacía, así que llamar a la aserción entregada contra un `404`/`422`/`403`/`409` genuino fallaba siempre. Un matcher y un renderizador que se entregan en el mismo framework discrepaban sobre la forma del mismo documento.
- Por eso las propias pruebas de Lumen — como la propia suite de pruebas de remate de `firefly/web` — afirman los campos RFC-7807 directamente en su lugar:
+ El RFC 9457 §3.1.1 lo zanjó: un `type` ausente **es** `about:blank`, así que escribirlo no le cuesta nada a un documento conforme y le ahorra a cada cliente tener que saberse la regla. El renderizador ahora lo emite — el `ProblemDetail` de Spring toma la misma decisión — y `firefly.web.problem.type-uri` o bien apunta a una URI base, derivando un tipo abrible a partir del `code` estable (`https://api.example.test/problems/resource-not-found`), o bien se pone a `''` para volver a omitir el miembro en un despliegue que quiera el documento pre-9457 byte por byte. Así que la aserción funciona contra las propias respuestas de error del framework, y `samples/lumen` la ejercita sobre el 404:
```php
it('returns RFC-7807 problem+json for an unknown wallet', function () {
// …
- $this->getJson('/api/v1/wallets/nope/balance')
+ $response = $this->getJson('/api/v1/wallets/nope/balance')
->assertStatus(404)
->assertHeader('Content-Type', 'application/problem+json')
->assertJsonPath('status', 404)
->assertJsonPath('code', 'RESOURCE_NOT_FOUND')
->assertJsonPath('title', 'Not Found');
+ // …
+ expect($response)->toBeProblemDetails(404);
```
-Un libro que solo enseñe lo que genuinamente funciona te haría un flaco favor ocultando esto: `toBeProblemDetails()` es real, se entrega, y la propia suite del paquete de pruebas lo ejercita contra un cuerpo que *sí* lleva una clave `type` — simplemente no es la aserción a la que Lumen recurre contra las respuestas de error reales del framework, y ahora sabes por qué, en lugar de toparte con el mismo fallo en frío.
+Las aserciones sobre campos se quedaron, y esa es la parte que conviene copiar: `toBeProblemDetails()` dice que la respuesta es *un* documento de problema de *ese* estado, mientras que `assertJsonPath('code', 'RESOURCE_NOT_FOUND')` dice cuál es el fallo. La segunda es la que una regresión tumba, así que una prueba que pueda permitirse ambas debería tener ambas.
---
@@ -781,7 +783,7 @@ Lee dos veces la última frase, porque es la razón por la que este método exis
## Ponlo en práctica {.exercises}
1. **Escribe una prueba de rebanada para uno de los propios controladores de Lumen.** Usando `WebSliceTestCase`, escanea solo `Lumen\Web\` y confirma que puedes servir un endpoint de wallet sin arrancar CQRS, EDA ni seguridad en absoluto — luego anota qué peticiones fallan, y por qué, una vez que entiendas exactamente qué beans dejó fuera la rebanada.
-2. **Reproduce tú mismo la brecha de `toBeProblemDetails()`.** Llámala contra una respuesta `404` real de la propia API REST de Lumen y confirma que falla con el mensaje de clave-`type`-faltante que describió este capítulo — luego reescribe la misma aserción de la manera en que `WalletRestTest.php` realmente lo hace, y confirma que esa pasa.
+2. **Mira cómo se mueve `type` bajo `toBeProblemDetails()`.** Llama a `toBeProblemDetails(404)` contra un `404` real de la propia API REST de Lumen y confirma que pasa — la clave `type` de la que habla la nota de este capítulo es `about:blank`. Luego pon `firefly.web.problem.type-uri` a `https://api.example.test/problems`, vuelve a ejecutarla, y confirma que el miembro pasó a ser `https://api.example.test/problems/resource-not-found` mientras la aserción sigue pasando. Por último pon la clave a `''` y observa cómo esa misma aserción falla con el mensaje de clave-`type`-faltante — el fallo que describe la nota, ahora algo que elige un despliegue en vez de algo que el framework te hace.
3. **Ejecuta la suite de navegador, y luego rompe una página.** Ejecuta `composer test:browser` y mira `tests/Browser/Screenshots`. Después añade a la vista de bienvenida un `
- @endpush
@endsection
diff --git a/packages/admin/resources/views/http.blade.php b/packages/admin/resources/views/http.blade.php
index 5e3cc13f..3e7e2589 100644
--- a/packages/admin/resources/views/http.blade.php
+++ b/packages/admin/resources/views/http.blade.php
@@ -12,33 +12,43 @@
@include('firefly-admin::_panel-head', [
- 'title' => 'Exchanges', 'count' => count($exchanges),
- 'filter' => 'http-body', 'placeholder' => 'Filter by path, status or id…',
+ 'title' => 'Exchanges', 'count' => $slice->total, 'query' => $query,
+ 'placeholder' => 'Search by path, status or id…',
])
- @if ($exchanges === [])
- @include('firefly-admin::_empty', [
- 'title' => 'No exchanges recorded',
- 'body' => 'Recording is off, or nothing has been served since this process started. Under PHP-FPM the buffer must be cache-backed to survive a request — see firefly.observability.httpexchanges.',
- ])
+ @if ($slice->isEmpty())
+ @include('firefly-admin::_empty', $query->isFiltered()
+ ? ['title' => 'Nothing matches', 'body' => 'No exchange\'s path, method, status or id contains that. Show them all.']
+ : ['title' => 'No exchanges recorded', 'body' => 'Recording is off, or nothing has been served since this process started. Under PHP-FPM the buffer must be cache-backed to survive a request — see firefly.observability.httpexchanges.'])
@else
-
-
When
Method
Path
Status
Took
Correlation
Trace
+
+ @include('firefly-admin::_table-head', ['view' => $view, 'query' => $query])
+ {{-- The id stays on the although the JavaScript row filter that named it is
+ gone: tests/Browser/ObservabilityTest.php reaches into `#http-body` to read the
+ Trace column's `title` back out of the DOM, which is the one assertion in this
+ wave's reach that can prove a trace id survived the whole round trip. --}}
- @foreach ($exchanges as $exchange)
+ @foreach ($slice->rows as $exchange)
+ {{-- The age is lossy both ways — `2h ago` does not say which two hours, and
+ the dated arm past 24h rounds the seconds off — so the full instant rides
+ on the title, the way the path and the two ids on this row already do.
+ Conditional rather than an empty string, because a row with no timestamp
+ draws an em-dash and has no instant to offer: `title=""` on it would be a
+ tooltip promising a value it does not have. --}}
+
+ @include('firefly-admin::_pager', ['slice' => $slice, 'query' => $query])
@endif
@endsection
diff --git a/packages/admin/resources/views/layout.blade.php b/packages/admin/resources/views/layout.blade.php
index 9b0eeaa9..619cf6fc 100644
--- a/packages/admin/resources/views/layout.blade.php
+++ b/packages/admin/resources/views/layout.blade.php
@@ -56,6 +56,16 @@
--rail:224px;
--top:52px;
--r:10px;
+ /*
+ | THE TABLE METRICS, published as custom properties because a
has to do arithmetic with
+ | them. `--row-x` is the horizontal cell padding, and every rigid column width is emitted as
+ | `calc(ch + 2 * var(--row-x))` — box-sizing is border-box below, so a bare `ch` would be
+ | characters MINUS both paddings, which is about 28px of a pill column and is where the
+ | verb DELETE was being clipped. `--table-vh` is the height of the scroll box a table lives in.
+ */
+ --row-x:{{ $settings->table->rowPaddingX() }};
+ --row-y:{{ $settings->table->rowPaddingY() }};
+ --table-vh:{{ $settings->table->maxHeight }};
--shadow:0 1px 2px rgba(24,20,12,.05), 0 6px 18px -12px rgba(24,20,12,.25);
}
html[data-theme="dark"], html[data-theme="auto"] .dark-probe { }
@@ -251,36 +261,133 @@
.stat a{color:inherit}
/* ── tables ──────────────────────────────────────────────────────── */
- .tw{overflow-x:auto}
+ /*
+ STICKY HAS NEVER WORKED HERE, AND THIS LINE IS WHY. `thead th` has carried `position:sticky;top:0`
+ for as long as the sheet has existed, and `.tw{overflow-x:auto}` computes `overflow-y` to `auto`
+ as well — which makes `.tw` the nearest scrollport for those headers. But `.tw` had NO height
+ constraint anywhere in the sheet, so it never scrolled: `main` did. A sticky element does not
+ follow an ancestor's scrollport, so the header stayed pinned to a box that was moving, and a
+ reviewer measured the
at `top: -360px` after scrolling. Giving the wrapper a height is the
+ entire fix, and `--table-vh` is how a deployment sizes it — or sets `none` and gets the old
+ whole-document scroll back, header and all.
+ */
+ .tw{overflow:auto;max-height:var(--table-vh)}
+ /*
+ THE OPT-OUT, AND EXACTLY WHO TAKES IT: a wrapper around a table with NO ``. The scrollport
+ above exists to give `position:sticky` something to stick to, so a headerless summary panel gains
+ nothing from it and pays twice — a second scrollbar inside a page that already has one, and a
+ footer link ("All traffic →") pushed under a box the reader has to scroll past to reach it. The
+ four panels on the overview are the whole of that set and they carry `class="tw free"`; every
+ other `.tw` on the dashboard wraps a table that draws a header and wants the scrollport.
+ Note what this class is NOT for: a table with few rows. `max-height` reserves no space, so a
+ four-row health table is four rows tall either way — the rule only ever bites once the rows
+ overflow, and by then a pinned header is what the reader wants.
+ */
+ .tw.free{max-height:none}
+ /* A nested scroller should stop at its own end rather than handing the gesture to the page mid-table. */
+ .tw{overscroll-behavior:contain}
table{border-collapse:collapse;width:100%;font-size:13px}
thead th{
- position:sticky;top:0;z-index:1;text-align:left;padding:8px 14px;background:var(--panel-2);
- border-bottom:1px solid var(--line);color:var(--ink-3);
+ position:sticky;top:0;z-index:1;text-align:left;padding:var(--row-y) var(--row-x);background:var(--panel-2);
+ color:var(--ink-3);
font-size:10px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;white-space:nowrap;
}
- tbody td{padding:8px 14px;border-bottom:1px solid var(--line);vertical-align:top}
+ /*
+ A BOX-SHADOW, NOT A BORDER. Under `border-collapse:collapse` the border of a sticky
belongs
+ to the TABLE's border grid rather than to the cell, so it stays behind with the rows and the
+ header scrolls out from under its own underline — the one visual cue that the header is a header.
+ An inset shadow is painted by the cell and travels with it.
+ */
+ thead th{box-shadow:inset 0 -1px 0 var(--line)}
+ tbody td{padding:var(--row-y) var(--row-x);border-bottom:1px solid var(--line);vertical-align:top}
tbody tr:last-child td{border-bottom:0}
tbody tr:hover{background:var(--hover)}
td.mono,th.mono{font-family:var(--mono);font-size:12.5px}
td.num{text-align:right;font-family:var(--mono);font-variant-numeric:tabular-nums;white-space:nowrap}
td.tight{width:1%;white-space:nowrap}
.dim{color:var(--ink-3)}
- .wrap{overflow-wrap:anywhere}
/* Long free-text cells stop growing past a readable measure instead of stretching the row to the
- full window width, which on a 1920 screen put a label at x=270 and its value at x=1855. */
+ full window width, which on a 1920 screen put a label at x=270 and its value at x=1855. Both of
+ these belong to the AUTO-layout tables the overview and the record page still draw — a listing
+ declares its widths from PHP instead, and neither class appears on a `table.ftable`. */
td.text{max-width:64ch}
td.num.pin{width:1%}
+ /*
+ `anywhere`, AND IT HAS TO STAY `anywhere` HERE. Per CSS Text 3 both values break an unbreakable
+ token at render time; only `anywhere` also CONTRIBUTES its break opportunities to min-content
+ sizing. Under `table-layout:auto` — which is every table still carrying this class: health, http,
+ metrics, the overview and a record's detail table in the data browser — min-content sizing is the
+ whole layout, and it is what keeps a base64 key or a JSON blob INSIDE its column. Measured with
+ `break-word` instead: a 130-character `FIREFLY_…` value made an env table 1253px wide inside a
+ 618px panel. The fixed-layout listings do not want this trade and do not take it — see
+ `table.ftable td.t-text` below.
+ */
+ .wrap{overflow-wrap:anywhere}
- /* A fully-qualified class name has no spaces, so overflow-wrap:anywhere breaks it mid-word —
- "SecurityHeadersFilte / r". Showing the short name on its own line and eliding the namespace under
- it keeps the column scannable: you read class names down the page instead of decoding wraps. */
- /* max-width:0 with width:100% is the standard way to make ONE table cell absorb the slack and
- truncate, instead of the longest class name widening the table until the columns beside it are
- pushed off the panel — which is what clipped the condition column. */
- .cls{max-width:0;width:100%}
- .cls .nm,.cls .ns{display:block;font-family:var(--mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
- .cls .nm{font-size:12.5px}
- .cls .ns{font-size:11px;color:var(--ink-3)}
+ /*
+ THE TWO-LINE QUALIFIED CELL. A fully-qualified class name has no spaces, so any wrap breaks it
+ mid-word — "SecurityHeadersFilte / r". Showing the short name on its own line and eliding the
+ namespace under it keeps the column scannable: you read class names down the page instead of
+ decoding wraps.
+
+ WHAT WAS WRONG WAS THE WIDTH MECHANISM, NOT THE IDEA. This used to be `.cls{max-width:0;width:100%}`
+ under a ten-line comment claiming it truncated the namespace with an ellipsis. It never fired:
+ min/max-width on a table cell is undefined in CSS 2.1 and Chrome ignores it under auto layout, so
+ the cell computed `max-width:0px` while rendering 853px wide with `scrollWidth === clientWidth` —
+ nothing was ever clipped, and all the declaration actually did was absorb every pixel of slack and
+ fling the neighbouring column to the far edge. The truncation now lives on the inner spans, where
+ it resolves against the definite width the
gives the column.
+ */
+ .cls .nm,.cls .ns,td.t-qual .nm,td.t-qual .ns{display:block;font-family:var(--mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+ .cls .nm,td.t-qual .nm{font-size:12.5px}
+ .cls .ns,td.t-qual .ns{font-size:11px;color:var(--ink-3)}
+ /*
+ A STEM ELIDES FROM THE LEFT. `App\Http\Controllers\Api\V1` is located by its TAIL — `Api\V1` is
+ the part that distinguishes it from its neighbours — so clipping the end would remove the only
+ informative half. An RTL container anchors the overflow at the start; `unicode-bidi:plaintext`
+ then takes each line's own direction from its first strong character, which for a Latin class
+ name is LTR, so the text reads normally and only the ellipsis moves.
+ */
+ .ns.stem{direction:rtl;unicode-bidi:plaintext;text-align:left}
+
+ /* ── the listing table ───────────────────────────────────────────────
+ ONE MECHANISM. This generalises `table.datatable`, which was already the only real column system
+ in this sheet — typed classes, widths from the type — into something every listing declares from
+ PHP. `table-layout:fixed` plus a
computed by TableView is what makes it hold: with
+ `auto` the browser sizes from content and no amount of CSS wins that argument.
+ */
+ table.ftable{table-layout:fixed}
+ /* `ch` on a
resolves against the
's OWN font, which inherits from
— 13px sans —
+ while the cells these widths size are 12.5px mono. Declaring the colgroup's font is what makes
+ every number in TableColumn mean the character it was counting. */
+ table.ftable colgroup{font:12.5px var(--mono)}
+ table.ftable td,table.ftable th{overflow:hidden}
+ table.ftable thead th a{display:inline-flex;align-items:center;gap:3px;color:inherit}
+ table.ftable thead th a:hover{color:var(--accent);text-decoration:none}
+ table.ftable td.t-pill,table.ftable th.t-pill{white-space:nowrap}
+ table.ftable td.t-num,table.ftable th.t-num{text-align:right;font-family:var(--mono);font-variant-numeric:tabular-nums;white-space:nowrap}
+ table.ftable th.t-num a{justify-content:flex-end}
+ /* `text-overflow` for the same reason the three kinds below carry it. A rigid column is sized from
+ the alphabet its formatter can emit, and the day a formatter grows an arm wider than that, the
+ cell has to SAY it was cut: without this, `overflow:hidden` takes the tail off mid-glyph and a
+ truncated timestamp reads as a complete, wrong one. The full instant is on the cell's title. */
+ table.ftable td.t-stamp{font-family:var(--mono);font-size:12px;color:var(--ink-3);white-space:nowrap;text-overflow:ellipsis}
+ table.ftable td.t-meter{vertical-align:middle}
+ table.ftable td.t-actions{white-space:nowrap}
+ /* Clipped to one line with the full value on the title: a table whose row height depends on its
+ longest json blob is not a table. */
+ table.ftable td.t-token,table.ftable td.t-path,table.ftable td.t-line{
+ font-family:var(--mono);font-size:12.5px;white-space:nowrap;text-overflow:ellipsis;
+ }
+ table.ftable td.t-line{color:var(--ink-2)}
+ /* `break-word`, NOT `anywhere`, and here the difference costs nothing: a fixed layout takes its
+ widths from the
and never asks a cell for its min-content size, so the only thing
+ either value decides is where a long token breaks when it is drawn. `break-word` keeps whole
+ words whole and breaks only what has no break of its own, which is how a wrapped message reads. */
+ table.ftable td.t-text{overflow-wrap:break-word}
+ table.ftable td.t-qual{font-family:var(--mono)}
+ /* A sorted header's arrow. One per table: ListingQuery::indicator() returns '' for every other column. */
+ th .ord{display:inline-block;width:10px;color:var(--brand)}
/* ── verb + status pills ─────────────────────────────────────────── */
.verb{
@@ -319,24 +426,13 @@
.pair{display:inline-flex;align-items:baseline;gap:5px;margin:0 8px 4px 0;font-family:var(--mono);font-size:11.5px;white-space:nowrap}
/* ── The data grid ─────────────────────────────────────────────────────────────────────────────
- A table is read DOWN a column, not across a row, so the type decides the alignment: figures are
- right-aligned with tabular numerals so digits line up and a long number is visibly long, and text
- stays left. Everything is clipped to one line with the full value on the title, because a table
- whose row height depends on its longest json blob is not a table.
+ The data browser's own type classes, on top of the shared `.ftable` vocabulary. They are the
+ DATABASE's types rather than the dashboard's kinds — int, float, bool, datetime, string, json —
+ and they survive because a column's type is a fact about the resource that DataSchema derived,
+ not a presentation choice a view makes. The width behaviour they used to carry is gone: the
+
declares it now, which is the same idea this block invented and the rest of the sheet
+ has finally caught up with.
*/
- /* THE CLASS IS `datatable`, NOT `grid`. It was `grid`, which is also this layout's own utility for
- a CSS grid of panels — so the table became a grid CONTAINER, thead and tbody became independent
- blocks, and the two rows laid out their columns separately: headers bunched into the left third
- with the values spread across the full width beneath them. A one-word collision, invisible in the
- markup, and only findable by asking the browser what `display` the table had ended up with.
-
- A CAP, NOT A COLLAPSE. This was `max-width:0`, which is the trick for clipping a cell in a
- table that has explicit column widths — and this table has none, so auto-layout sized every
- column from its HEADER while the values overflowed their boxes: headers bunched into the left
- third and data spread across the full width, misaligned from the row above it. A real cap lets
- auto-layout size a column from its content up to a limit, which is what keeps the two rows in
- the same grid. */
- table.datatable{table-layout:auto}
table.datatable td.cell{max-width:34ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--mono);font-size:12.5px;vertical-align:middle}
table.datatable td.cell .v,table.datatable td.cell .idv{overflow:hidden;text-overflow:ellipsis;display:block}
table.datatable thead th{vertical-align:middle}
@@ -344,23 +440,12 @@
table.datatable thead th.t-int,table.datatable thead th.t-float{text-align:right}
table.datatable thead th.t-int a,table.datatable thead th.t-float a{justify-content:flex-end}
table.datatable thead th a{display:inline-flex;align-items:center;gap:3px}
- /* COLUMNS THAT CANNOT BE LONG SHOULD NOT BE WIDE. Auto-layout splits leftover width evenly, which
- gave a one-digit `quantity` the same 200px as a timestamp and left the text columns cramped
- between them. `width:1%` is the table idiom for "shrink to content": the numeric, boolean and
- datetime columns take exactly what they need and the string and json columns absorb everything
- left over, which is where a reader actually needs the room. */
- table.datatable td.t-int,table.datatable td.t-float,table.datatable td.t-bool,table.datatable td.t-datetime,
- table.datatable thead th.t-int,table.datatable thead th.t-float,table.datatable thead th.t-bool,table.datatable thead th.t-datetime{
- width:1%;white-space:nowrap;
- }
- table.datatable td.t-string,table.datatable td.t-json{width:auto}
table.datatable td.t-json{color:var(--ink-2)}
table.datatable td.t-datetime{color:var(--ink-2);white-space:nowrap}
table.datatable td.nil{text-align:center}
table.datatable td.secret .v{color:var(--ink-3);letter-spacing:.08em}
.nul{color:var(--ink-3)}
.idv{font-weight:650}
- th .ord{display:inline-block;width:10px;color:var(--brand)}
/* Two states read faster as a shape than as a word. */
.bool{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;font-weight:600}
@@ -428,6 +513,31 @@
}
.act:hover{border-color:var(--accent);color:var(--accent)}
+ .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
+ :root{--mod-l:38%}
+ html[data-theme="dark"]{--mod-l:62%}
+ @media(prefers-color-scheme:dark){html[data-theme="auto"]{--mod-l:62%}}
+ .bx-module-map{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:14px;margin:16px 0}
+ .bx-module{display:flex;flex-direction:column;gap:7px;padding:12px;border:1px solid var(--line);border-left:4px solid hsl(var(--hue) 62% var(--mod-l));border-radius:5px;color:var(--ink);overflow-wrap:break-word}
+ .bx-search,.bx-controls,.bx-module-list{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:12px 0}
+ .bx-search input{min-width:240px;padding:8px;color:var(--ink);background:var(--panel);border:1px solid var(--line-2)}
+ .bx-details,.bx-controls,.bx-caption{padding:14px}
+ .bx-details li{margin:8px 0}
+ .bx{overflow:auto;padding:10px 14px;background:var(--panel-2)}
+ .bx-heads{display:grid;margin-bottom:12px;color:var(--ink-2);font-size:11px;font-weight:600}
+ .bx-drawing{position:relative;margin:4px 0}
+ .bx-edges{position:absolute;inset:0;overflow:visible;pointer-events:none}
+ .bx-edge{fill:none;stroke:var(--ink-3);stroke-width:1.2}
+ .bx-edge.cycle{stroke-dasharray:6 3;stroke-width:2}
+ .bx-edge.produces{stroke-dasharray:5 4}
+ .bx-node{position:absolute;display:block;width:176px;height:38px;border:1px solid var(--line-2);border-left:3px solid hsl(var(--hue) 62% var(--mod-l));border-radius:5px;padding:3px 7px;color:var(--ink);background:var(--panel);text-decoration:none;font-size:12px;line-height:14px}
+ .bx-name{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:25px}
+ .bx-sigil{position:absolute;right:5px;top:3px;font-size:11px;color:var(--ink-2)}
+ .bx-sub{font-size:11px;color:var(--ink-2)}
+ .bx-controls .act[aria-current="true"]{font-weight:700;border:2px solid var(--ink);text-decoration:underline}
+ .bx-node.is-focus{border:2px solid var(--accent)}
+ .bx-node:focus-visible,.bx:focus-visible{outline:3px solid var(--accent);outline-offset:3px}
+ .bx-node:hover{background:var(--panel-2)}
/* ── bean graph ──────────────────────────────────────────────────── */
.legend{display:flex;flex-wrap:wrap;gap:6px;padding:11px 14px;border-bottom:1px solid var(--line);background:var(--panel-2)}
.mod{
@@ -435,61 +545,12 @@
border:1px solid var(--line-2);background:transparent;color:var(--ink-2);cursor:pointer;
font-family:var(--mono);font-size:11px;
}
- .mod i{width:8px;height:8px;border-radius:2px;background:hsl(var(--hue) 62% 46%);flex:none}
+ .mod i{width:8px;height:8px;border-radius:2px;background:hsl(var(--hue) 62% var(--mod-l));flex:none}
.mod:hover{border-color:var(--ink-3);color:var(--ink)}
.mod[aria-pressed="false"]{opacity:.42;text-decoration:line-through}
- .graph{display:grid;grid-template-columns:minmax(0,1fr) 268px}
- @media(max-width:1100px){.graph{grid-template-columns:1fr}}
- .graph .canvas{position:relative;height:min(72vh,720px);overflow:hidden;background:var(--panel-2);cursor:grab;touch-action:none}
- .graph .canvas.grabbing{cursor:grabbing}
- .graph .canvas svg{width:100%;height:100%;display:block}
- .hint-bar{
- position:absolute;left:10px;bottom:8px;font-size:11px;color:var(--ink-3);
- background:color-mix(in srgb, var(--panel) 84%, transparent);padding:3px 8px;border-radius:6px;
- pointer-events:none;
- }
- .inspect{border-left:1px solid var(--line);padding:12px 14px;overflow:hidden auto;height:min(72vh,720px);background:var(--panel);min-width:0}
- @media(max-width:1100px){.inspect{border-left:0;border-top:1px solid var(--line);height:auto;max-height:320px}}
- .inspect .blank{color:var(--ink-3);font-size:13px;padding:18px 0}
- .inspect .who{margin-bottom:12px}
- .inspect .who strong{display:block;font-size:14.5px}
- .inspect .who code{display:block;margin-top:4px;font-size:10.5px;overflow-wrap:anywhere;background:none;border:0;padding:0;color:var(--ink-3)}
- .inspect h4{margin:12px 0 5px;font-size:10px;font-weight:700;letter-spacing:.13em;text-transform:uppercase;color:var(--ink-3)}
- .inspect h4 span{color:var(--ink-2);letter-spacing:0}
- .inspect ul{list-style:none;margin:0;padding:0}
- .inspect li button{
- display:block;width:100%;text-align:left;background:none;border:0;padding:3px 0;cursor:pointer;
- font-family:var(--mono);font-size:11.5px;color:var(--accent);
- overflow-wrap:anywhere;line-height:1.4;
- }
- .inspect li button:hover{text-decoration:underline}
- .inspect .none{margin:0;font-size:12.5px;color:var(--ink-3)}
.canvas{overflow:auto;padding:14px;background:var(--panel-2);max-height:70vh}
.canvas svg{display:block;margin-inline:auto}
- .edges .edge{fill:none;stroke:var(--line-2);stroke-width:1.3;color:var(--line-2);transition:stroke .12s,opacity .12s}
- .edges .edge.via{stroke-dasharray:4 3}
- /* A `produces` edge is structure, not a dependency the author wrote — drawn quieter so the wiring
- the reader came to see stays the loudest thing on the canvas. */
- .edges .edge.produces{stroke-dasharray:1 4;opacity:.55}
- .edges .edge.lit{stroke:var(--accent);color:var(--accent);stroke-width:2;opacity:1}
- .edges .edge.dimmed{opacity:.08}
- .edges .edge.off{display:none}
-
- .nodes .node{cursor:pointer}
- .nodes .node rect{fill:var(--panel);stroke:var(--line-2);stroke-width:1.1;transition:stroke .12s,fill .12s}
- .nodes .node .stripe{fill:hsl(var(--hue) 62% 46%);stroke:none}
- .nodes .node text{font-family:var(--mono);font-size:11px;fill:var(--ink);pointer-events:none}
- .nodes .node text.sub{font-size:9.5px;fill:var(--ink-3)}
- /* A #[Bean] product is a value a factory returns, not a class the scanner found — dashed says so. */
- .nodes .node.k-bean rect{stroke-dasharray:3 2}
- .nodes .node.k-config rect{fill:var(--hover)}
- .nodes .node:hover rect,.nodes .node.lit rect{stroke:var(--accent);fill:var(--accent-soft)}
- .nodes .node.picked rect{stroke:var(--accent);stroke-width:2}
- .nodes .node.dimmed{opacity:.22}
- .nodes .node.off{display:none}
- .nodes .node:focus-visible rect{stroke:var(--accent);stroke-width:2}
-
/* ── data browser ────────────────────────────────────────────────── */
.pager{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:10px 14px;
border-top:1px solid var(--line);font-size:12.5px;color:var(--ink-2)}
@@ -513,6 +574,30 @@
padding:11px 14px;font-size:13.5px}
[hidden]{display:none!important}
+ .route-head{display:block}
+ .route-head h1{font-size:24px;margin:16px 0 10px;overflow-wrap:break-word;scroll-margin-top:16px}
+ .route-head+.stats{margin-bottom:16px}
+ .route-links{display:flex;flex-wrap:wrap;gap:8px 20px;margin:14px 0}
+ .sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}
+ .route-link{display:inline}
+ .route-panel{padding:18px 20px;overflow-wrap:break-word}
+ .route-panel h2{font-size:16px;margin:0 0 10px}
+ .route-panel h3{font-size:14px;margin:20px 0 8px}
+ .route-panel p{margin:8px 0}
+ .route-panel summary,.route-siblings summary{cursor:pointer;font-weight:600}
+ .route-siblings{margin-top:16px}
+ .route-siblings ul,.route-injected{padding-left:22px}
+ .route-siblings li,.route-injected li{margin:10px 0}
+ table.route-bindings{min-width:900px}
+ .route-failures{list-style:none;padding:0}
+ .route-failures li{padding:10px 0;border-bottom:1px solid var(--line)}
+ .route-failures li:last-child{border:0}
+ .route-failures li>span:last-child{display:block;margin-top:4px}
+ .body-tree{list-style:none;padding-left:18px;border-left:1px solid var(--line)}
+ .body-tree li{margin:9px 0}
+ .body-tree .body-tree .body-tree{padding-left:0;border-left:0}
+ .route-panel pre{white-space:pre-wrap;overflow-wrap:anywhere;max-width:100%}
+ .route-head h1:focus{outline:2px solid var(--accent);outline-offset:5px}
@@ -617,74 +702,47 @@ function start() {
});
});
- // Bean graph: hovering or clicking a node lights its edges and both endpoints, and dims everything
- // else. Reading a dependency diagram is asking "what touches THIS", and a static picture cannot
- // answer that once there is more than a handful of nodes.
+ // KEEPING THE READING POSITION WHERE THE BROWSER USED TO KEEP IT ITSELF. The auto-refresh reloads the
+ // whole page, which is the right thing for a server-rendered dashboard — the URL carries the page,
+ // the sort and the search, so a reader on page 7 comes back to page 7. Until this wave the reading
+ // position came back too, for free: `main` was the scrollport and a browser restores the DOCUMENT's
+ // offset across a reload. Giving `.tw` a height moved the scrollport inside the panel, and a browser
+ // does not restore an inner scroller — so the refresh started dropping the reader back to row 1.
+ //
+ // RESTORED ONLY WHERE THE BROWSER WOULD HAVE RESTORED IT — `reload` and `back_forward`, read off the
+ // Navigation Timing entry. A plain `navigate`, which is what clicking Beans in the sidebar an hour
+ // later is, leaves the saved offset alone and opens the table at the top: arriving at a page that is
+ // already scrolled into the middle of a table, with the document itself at the top and nothing on
+ // screen to explain it, is a defect rather than a convenience. An engine with no navigation entry
+ // gets the same treatment, because not restoring is the answer that can only disappoint.
+ //
+ // Keyed by the full URL, so page 7's position is never applied to page 8. Saved on `pagehide` rather
+ // than `beforeunload`: an unconditional `beforeunload` listener makes the page ineligible for the
+ // back/forward cache, which is one of the two navigations this exists to serve.
+ //
+ // `firefly.admin.table.remember-scroll` is the way out; the block is not emitted when it is off.
+ @if ($settings->table->rememberScroll)
(function () {
- var svg = document.querySelector('.canvas svg');
- if (!svg) { return; }
-
- var nodes = svg.querySelectorAll('.node');
- var edges = svg.querySelectorAll('.edge');
-
- function clear() {
- nodes.forEach(function (n) { n.classList.remove('lit', 'dimmed'); });
- edges.forEach(function (e) { e.classList.remove('lit', 'dimmed'); });
+ var key = 'firefly-admin-scroll:' + window.location.pathname + window.location.search;
+ var wrappers = document.querySelectorAll('.tw');
+ var entry = window.performance && performance.getEntriesByType
+ ? performance.getEntriesByType('navigation')[0]
+ : null;
+
+ if (entry && (entry.type === 'reload' || entry.type === 'back_forward')) {
+ try {
+ var saved = JSON.parse(sessionStorage.getItem(key) || '[]');
+ wrappers.forEach(function (wrapper, index) { wrapper.scrollTop = saved[index] || 0; });
+ } catch (e) { /* private mode, or a stale shape */ }
}
- function focus(id) {
- var touched = {};
- touched[id] = true;
- edges.forEach(function (edge) {
- var from = edge.getAttribute('data-from'), to = edge.getAttribute('data-to');
- if (from === id || to === id) {
- edge.classList.add('lit');
- edge.classList.remove('dimmed');
- touched[from] = true;
- touched[to] = true;
- } else {
- edge.classList.add('dimmed');
- edge.classList.remove('lit');
- }
- });
- nodes.forEach(function (node) {
- var hit = touched[node.getAttribute('data-id')];
- node.classList.toggle('lit', !!hit);
- node.classList.toggle('dimmed', !hit);
- });
- }
-
- var pinned = null;
- nodes.forEach(function (node) {
- var id = node.getAttribute('data-id');
- node.addEventListener('mouseenter', function () { if (!pinned) { focus(id); } });
- node.addEventListener('mouseleave', function () { if (!pinned) { clear(); } });
- node.addEventListener('click', function () {
- pinned = pinned === id ? null : id;
- pinned ? focus(pinned) : clear();
- });
+ window.addEventListener('pagehide', function () {
+ try {
+ sessionStorage.setItem(key, JSON.stringify(Array.prototype.map.call(wrappers, function (w) { return w.scrollTop; })));
+ } catch (e) { /* private mode, or the quota */ }
});
- svg.addEventListener('click', function (event) {
- if (event.target === svg) { pinned = null; clear(); }
- });
-
- // The graph filter highlights rather than hides: removing a node would silently remove its
- // edges too, and an edge to something you cannot see is worse than no filter at all.
- var find = document.querySelector('[data-filter="graph-body"]');
- if (find) {
- find.addEventListener('input', function () {
- var needle = find.value.toLowerCase();
- pinned = null;
- if (needle === '') { clear(); return; }
- nodes.forEach(function (node) {
- var hit = (node.getAttribute('data-search') || '').indexOf(needle) !== -1;
- node.classList.toggle('lit', hit);
- node.classList.toggle('dimmed', !hit);
- });
- edges.forEach(function (e) { e.classList.add('dimmed'); e.classList.remove('lit'); });
- });
- }
})();
+ @endif
// "/" focuses the first filter on the page — the shortcut every log and table UI uses.
document.addEventListener('keydown', function (event) {
diff --git a/packages/admin/resources/views/loggers.blade.php b/packages/admin/resources/views/loggers.blade.php
index ec8e2624..a75d2cc9 100644
--- a/packages/admin/resources/views/loggers.blade.php
+++ b/packages/admin/resources/views/loggers.blade.php
@@ -1,10 +1,7 @@
@extends('firefly-admin::layout')
@section('title', 'Loggers')
@section('body')
- @php
- $levelNames = is_array($levels ?? null) ? $levels : [];
- $channels = is_array($loggers ?? null) ? $loggers : [];
- @endphp
+ @php $levelNames = is_array($levels ?? null) ? $levels : []; @endphp
Loggers
@@ -13,31 +10,39 @@
@include('firefly-admin::_panel-head', [
- 'title' => 'Channels', 'count' => count($channels),
- 'filter' => 'log-body', 'placeholder' => 'Filter channels…',
+ 'title' => 'Channels', 'count' => $slice->total, 'query' => $query, 'placeholder' => 'Search channels…',
])
- @if ($channels === [])
- @include('firefly-admin::_empty', [
- 'title' => 'No channels configured',
- 'body' => 'Nothing is defined under logging.channels.',
- ])
+ @if ($slice->isEmpty())
+ @include('firefly-admin::_empty', $query->isFiltered()
+ ? ['title' => 'Nothing matches', 'body' => 'No channel name or level contains that. Show them all.']
+ : ['title' => 'No channels configured', 'body' => 'Nothing is defined under logging.channels.'])
@else
Signature positions are preserved. Defaults are compiled attribute values, not necessarily PHP signature defaults. Null does not establish nullability; an unrecorded type may be union, intersection or untyped.
+ @if ($detail->caller === [])
No caller-supplied arguments in the compiled contract.
{{ $binding->plan['valid'] && $binding->plan['type'] !== null ? 'Validated before hydration; validation can answer 422.' : 'No body validation is declared.' }} Validation rules are not part of the route manifest.
{{ $binding->plan['type'] === null || ! class_exists($binding->plan['type']) ? 'The decoded array is passed through.' : 'No constructor properties are recorded in this manifest.' }}
@endif
+
+ @endif
+ @endforeach
+
+
Response
+
Default success status {{ $route->status }}. A returned response can override it.
+
{{ $route->html ? 'The manifest records the #[Controller] HTML stereotype. This is declaration metadata, not a media-type guarantee.' : 'No HTML stereotype is recorded. Older manifests may omit this metadata.' }}
+
The returned value and Accept determine the response. Views and HTML-capable values can render as HTML; data is negotiated; returned responses retain their own headers.
+ @if ($route->html)
HTML controllers are excluded from the OpenAPI document unless firefly.openapi.include-html is enabled.
@endif
+
Exception handlers
+
Controller-local handlers take precedence over global handlers; within that scope, the most-derived matching exception class wins.
+ @if ($handlers === [])
No matching local or global exception handlers are registered.
Source: {{ $adviceSource }}. Listed outermost first. LIVE means the interceptor is bound; it does not prove a request executed it. UNBOUND advice can fail proxy creation unless explicitly permitted to be INERT.
+ @if ($advice === [])
No method advice is recorded in the available plan.
@include('firefly-admin::_panel-head', [
- 'title' => 'Meters', 'count' => count($metrics),
- 'filter' => 'metrics-body', 'placeholder' => 'Filter meters…',
+ 'title' => 'Meters', 'count' => $slice->total, 'query' => $query, 'placeholder' => 'Search meters…',
])
- @if ($metrics === [])
- @include('firefly-admin::_empty', [
- 'title' => 'Nothing recorded yet',
- 'body' => 'The default registry keeps meters in process memory, so under PHP-FPM a page only ever sees its own request. Set firefly.observability.metrics.store to a cache store to accumulate across workers.',
- ])
+ @if ($slice->isEmpty())
+ @include('firefly-admin::_empty', $query->isFiltered()
+ ? ['title' => 'Nothing matches', 'body' => 'No meter name or statistic contains that. Show them all.']
+ : ['title' => 'Nothing recorded yet', 'body' => 'The default registry keeps meters in process memory, so under PHP-FPM a page only ever sees its own request. Set firefly.observability.metrics.store to a cache store to accumulate across workers.'])
@else
-
-
Meter
Statistic
Value
Relative
-
- @foreach ($metrics as $metric)
+
+ @include('firefly-admin::_table-head', ['view' => $view, 'query' => $query])
+
+ @foreach ($slice->rows as $metric)
+ {{-- ONE ROW PER MEASUREMENT, ONE SLICE PER METER. The listing pages meters, so a
+ meter's statistics are drawn together however many there are, and only the
+ first of them names the meter — the blank cells under it are what says "these
+ readings are of one thing". --}}
@forelse ($metric['rows'] as $row)
-
{{ $loop->first ? $metric['name'] : '' }}
-
{{ $row['statistic'] }}
-
{{ $row['display'] }}
-
- {{-- One shared scale across every meter: the bar answers "which of these is
- large", which is the only comparison a mixed-unit list supports. --}}
+
+ {{-- One shared scale across every meter, and across every PAGE of them:
+ the bar answers "which of these is large", which is the only
+ comparison a mixed-unit list supports, and an answer that changed
+ when the reader turned the page would not be one. --}}
+ @include('firefly-admin::_pager', ['slice' => $slice, 'query' => $query])
@endif
@endsection
diff --git a/packages/admin/resources/views/oauth2.blade.php b/packages/admin/resources/views/oauth2.blade.php
index ab6b9909..9a70f3f9 100644
--- a/packages/admin/resources/views/oauth2.blade.php
+++ b/packages/admin/resources/views/oauth2.blade.php
@@ -3,9 +3,8 @@
@section('body')
@php
// Whether the Active counts came from a per-process store (the endpoint's own
- // `authorizations.processLocal`). Defaulted rather than assumed, so a payload that does not say is
- // rendered without a caveat instead of with one nothing substantiates.
- $processLocal = ($processLocalAuthorizations ?? false) === true;
+ // `authorizations.processLocal`), decided in AdminAction and carried here for the note below.
+ $processLocal = $processLocalAuthorizations;
@endphp
@@ -17,81 +16,65 @@
@include('firefly-admin::_panel-head', [
- 'title' => 'Clients', 'count' => count($clients),
- 'filter' => 'oauth2-body', 'placeholder' => 'Filter by client id, grant or scope…',
+ 'title' => 'Clients', 'count' => $slice->total, 'query' => $query,
+ 'placeholder' => 'Search by client id, grant or scope…',
])
- @if ($clients === [])
- @include('firefly-admin::_empty', [
- 'title' => 'No clients registered',
- 'body' => 'Add a block under firefly.security.oauth2.server.clients, or switch clients.driver to eloquent and register clients dynamically.',
- ])
+ @if ($slice->isEmpty())
+ @include('firefly-admin::_empty', $query->isFiltered()
+ ? ['title' => 'Nothing matches', 'body' => 'No client id, name, grant or scope contains that. Show them all.']
+ : ['title' => 'No clients registered', 'body' => 'Add a block under firefly.security.oauth2.server.clients, or switch clients.driver to eloquent and register clients dynamically.'])
@else
-
-
Client
Authentication
Grants
Scopes
Redirect URIs
Tokens
Active
+
+ @include('firefly-admin::_table-head', ['view' => $view, 'query' => $query])
- @foreach ($clients as $client)
- @php
- // Assembled here rather than with inline @if fragments: Blade only compiles a
- // directive that is NOT glued to a word character, so `…}}s@if(…) · PKCE@endif`
- // silently leaves an unclosed `if` in the compiled view.
- $issuance = [(string) ($client['accessTokenFormat'] ?? ''), ($client['accessTokenTtl'] ?? 0).'s'];
- // `requiresProofKey` (what the endpoints enforce), never `requireProofKey` (the
- // switch the client registered): `require_pkce` and
- // `require_proof_key_for_public_clients` both default to on, so the registered
- // switch reads "no PKCE" for a client whose every authorization request is in fact
- // refused without a code_challenge — the first thing this page is opened to explain.
- //
- // Strictly `=== true`, because both keys are `null` for a client with no
- // authorization_code grant: PKCE and consent are that path's rules, so a machine
- // client sends no code_challenge and reaches no consent screen, and the endpoint
- // says "does not apply" rather than reporting a default nothing enforces. A
- // truthy test would print both labels beside it and send the operator checking
- // two requirements that are not there.
- if (($client['requiresProofKey'] ?? null) === true) {
- $issuance[] = 'PKCE';
- }
- if (($client['requireAuthorizationConsent'] ?? null) === true) {
- $issuance[] = 'consent';
- }
- $active = is_numeric($client['activeAuthorizations'] ?? null) ? (int) $client['activeAuthorizations'] : 0;
- // A `0` counted in a per-process store is the one cell that reads as a fact and is
- // not one: the workers beside this one may be holding a hundred live
- // authorizations for this client, and an operator who reads "none" goes looking
- // for a token endpoint that is refusing nobody. Shown as `—` — nothing counted
- // here — while a non-zero count is kept, because that one is a floor the store
- // can vouch for. The note under the table names the key that makes it server-wide.
- $activeCell = $processLocal && $active === 0 ? '—' : (string) $active;
- @endphp
+ @foreach ($slice->rows as $client)
+ {{-- The client name is `class="ns"` WITHOUT `stem`: it is a human name, not a
+ qualified prefix, so it elides from the right like any prose rather than
+ from the left like a namespace. --}}
+
+ {{-- The `—` for a count no per-process store can vouch for is DRAWN HERE, over
+ the empty string AdminAction leaves in the row, exactly like the absent
+ cells above it. The row keeps a number so the column can be ordered as
+ one; see AdminAction::clientRows() for the page of em-dashes an em-dash in
+ the row would open a descending Active on. --}}
+
- @if ($processLocal)
- {{-- Said where the number is read, not in the release notes. Phrased as the store this
- process RESOLVED rather than as the value of the driver key or the name of a shipped
- class, because what the endpoint reports is the store's own processLocal() — an
- application may bind a per-process service of its own, and then neither the key nor the
- class says anything. --}}
-
Active counts this worker only. The authorization store this
- process resolved keeps its authorizations in the process — a map rebuilt in every PHP
- worker — so these are the ones held by the worker that rendered this page, and under
- php-fpm or Octane the next request lands on a different one. A client with live tokens
- elsewhere therefore shows — here. Set
- firefly.security.oauth2.server.authorizations.driver to eloquent
- (and run the oauth2_authorizations migration), or bind a durable
- OAuth2AuthorizationService of your own, for counts that describe the
- deployment.
- @endif
+ @include('firefly-admin::_pager', ['slice' => $slice, 'query' => $query])
+ @endif
+ @if ($processLocal)
+ {{-- Said where the number is read, not in the release notes. Phrased as the store this
+ process RESOLVED rather than as the value of the driver key or the name of a shipped
+ class, because what the endpoint reports is the store's own processLocal() — an
+ application may bind a per-process service of its own, and then neither the key nor the
+ class says anything.
+
+ OUTSIDE the @if on the listing, because a narrowed search that matches nothing does not
+ make the caveat untrue: the page is still counting one worker's authorizations, and a
+ reader who has just typed a term is exactly the reader about to conclude something from
+ an Active column they cannot see. --}}
+
Active counts this worker only. The authorization store this
+ process resolved keeps its authorizations in the process — a map rebuilt in every PHP
+ worker — so these are the ones held by the worker that rendered this page, and under
+ php-fpm or Octane the next request lands on a different one. A client with live tokens
+ elsewhere therefore shows — here. Set
+ firefly.security.oauth2.server.authorizations.driver to eloquent
+ (and run the oauth2_authorizations migration), or bind a durable
+ OAuth2AuthorizationService of your own, for counts that describe the
+ deployment.
@endif
@endsection
diff --git a/packages/admin/resources/views/overview.blade.php b/packages/admin/resources/views/overview.blade.php
index f87f7b26..2c7c2cf3 100644
--- a/packages/admin/resources/views/overview.blade.php
+++ b/packages/admin/resources/views/overview.blade.php
@@ -44,7 +44,7 @@
'body' => 'Implement Firefly\Actuator\Health\HealthIndicator and register it as a bean to see it here.',
])
@else
-
+
@foreach ($indicators as $indicator)
@@ -77,7 +77,7 @@
'body' => 'No InfoContributor has contributed anything. Set firefly.management.info.app, or register your own contributor.',
])
@else
-
+ @include('firefly-admin::_pager', ['slice' => $slice, 'query' => $query])
@endif
@endsection
diff --git a/packages/admin/src/AdminSettings.php b/packages/admin/src/AdminSettings.php
index c3b9f1c5..30a6413e 100644
--- a/packages/admin/src/AdminSettings.php
+++ b/packages/admin/src/AdminSettings.php
@@ -4,6 +4,7 @@
namespace Firefly\Admin;
+use Firefly\Admin\Table\TableSettings;
use Firefly\Config\Config;
/**
@@ -33,6 +34,10 @@ public function __construct(
public string $theme = 'auto',
public int $graphMaxNodes = 220,
public array $excludedPages = [],
+ public TableSettings $table = new TableSettings,
+ public bool $routeDetail = true,
+ public bool $routeAdvice = true,
+ public BeanGraphSettings $graph = new BeanGraphSettings,
) {}
public static function fromConfig(Config $config): self
@@ -47,11 +52,16 @@ public static function fromConfig(Config $config): self
// never finish and the dashboard would hammer the application it is supposed to be observing.
refreshSeconds: max(2, $config->int('firefly.admin.refresh-seconds', 10)),
theme: self::theme($config->string('firefly.admin.theme', 'auto')),
- // Past this, a dependency diagram is a hairball rather than something anyone can read, so the
- // graph page lists the relations instead of drawing them. Configurable because "unreadable"
- // depends on the screen and the application.
+ // Deprecated compatibility value; bounded focus views no longer read this ceiling.
graphMaxNodes: max(0, $config->int('firefly.admin.graph.max-nodes', 220)),
excludedPages: self::csv($config->string('firefly.admin.pages.exclude', '')),
+ // Every listing's paging, sorting and spacing. On AdminSettings rather than resolved separately
+ // because a Blade view reaches exactly one settings object, and a second one would mean every
+ // view that draws a table taking a second parameter through render().
+ table: TableSettings::fromConfig($config),
+ routeDetail: $config->bool('firefly.admin.routes.detail', true),
+ routeAdvice: $config->bool('firefly.admin.routes.advice', true),
+ graph: BeanGraphSettings::fromConfig($config),
);
}
diff --git a/packages/admin/src/BeanGraph.php b/packages/admin/src/BeanGraph.php
index ec45faac..43c53611 100644
--- a/packages/admin/src/BeanGraph.php
+++ b/packages/admin/src/BeanGraph.php
@@ -26,10 +26,8 @@
* the interface in `via` so the reader sees the indirection rather than being quietly shown something they
* did not write.
*
- * Layering is a longest-path assignment over the resolved edges, so a node sits below everything that
- * depends on it and arrows read downward. The walk carries its own visited set, so a cycle terminates and
- * the edge that closed it is REPORTED — which matters, because the container has no cycle detection and a
- * cycle among eager singletons exhausts memory at boot.
+ * Iterative Tarjan analysis condenses strongly connected components before assigning longest-path levels.
+ * Cycle membership is a wiring fact; production edges mean it need not be a runtime constructor cycle.
*/
final class BeanGraph
{
@@ -65,6 +63,7 @@ public function __construct(
public static function build(array $beans, array $configProperties = []): self
{
$index = new BeanGraphIndex;
+ $index->countProducers($beans, $configProperties);
foreach ($beans as $row) {
if (! is_array($row) || ! is_string($row['class'] ?? null)) {
@@ -74,7 +73,7 @@ public static function build(array $beans, array $configProperties = []): self
}
foreach ($configProperties as $class => $row) {
- if (is_array($row) && is_string($row['class'] ?? $class)) {
+ if (is_array($row) && ($row['bound'] ?? true) !== false && is_string($row['class'] ?? $class)) {
$index->addConfigProperties(is_string($row['class'] ?? null) ? $row['class'] : (string) $class);
}
}
@@ -84,8 +83,8 @@ public static function build(array $beans, array $configProperties = []): self
$degree = [];
foreach ($edges as $edge) {
- $degree[$edge['from']]['out'] = ($degree[$edge['from']]['out'] ?? 0) + 1;
- $degree[$edge['to']]['in'] = ($degree[$edge['to']]['in'] ?? 0) + 1;
+ $degree[$edge['from']]['out'][$edge['to']] = true;
+ $degree[$edge['to']]['in'][$edge['from']] = true;
}
$nodes = [];
@@ -93,8 +92,8 @@ public static function build(array $beans, array $configProperties = []): self
$nodes[] = [
...$node,
'level' => $levels[$id] ?? 0,
- 'in' => $degree[$id]['in'] ?? 0,
- 'out' => $degree[$id]['out'] ?? 0,
+ 'in' => count($degree[$id]['in'] ?? []),
+ 'out' => count($degree[$id]['out'] ?? []),
];
}
@@ -106,6 +105,7 @@ public static function build(array $beans, array $configProperties = []): self
/**
* Kept for the older two-argument shape.
*
+ *
* @param array $beans
*/
public static function fromCatalog(array $beans): self
@@ -113,7 +113,8 @@ public static function fromCatalog(array $beans): self
return self::build($beans);
}
- /** @return array node count per kind, for the page's summary */
+ /**
+ * @return array node count per kind, for the page's summary */
public function kindCounts(): array
{
$counts = [self::KIND_COMPONENT => 0, self::KIND_BEAN => 0, self::KIND_CONFIG => 0];
@@ -128,6 +129,7 @@ public function kindCounts(): array
* The namespace roots present, most-populated first — the drawing colours by module, and a legend has to
* name them.
*
+ *
* @return list
*/
public function modules(): array
@@ -156,9 +158,8 @@ public static function moduleOf(string $id): string
}
/**
- * Longest-path layering, so a node always sits below everything that depends on it. Depth is memoised and
- * the walk carries a visited set, so a cycle terminates instead of recursing forever — and the edge that
- * closed it is reported.
+ * Longest-path levels on the component DAG, with every internal cyclic edge retained for compatibility.
+ *
*
* @param list $ids
* @param list $edges
@@ -170,54 +171,59 @@ private static function levels(array $ids, array $edges): array
foreach ($edges as $edge) {
$out[$edge['from']][] = $edge['to'];
}
-
+ $components = GraphComponents::of($ids, $out);
$depth = [];
$cycles = [];
-
- $walk = static function (string $node, array $path) use (&$walk, &$depth, &$cycles, $out): int {
- if (isset($depth[$node])) {
- return $depth[$node];
- }
- if (isset($path[$node])) {
- return 0;
- }
-
- $path[$node] = true;
- $deepest = 0;
- foreach ($out[$node] ?? [] as $next) {
- if (isset($path[$next])) {
- $cycles[] = ['from' => $node, 'to' => $next];
-
- continue;
+ // Tarjan emits dependency components before their consumers.
+ foreach ($components->groups as $index => $members) {
+ $depth[$index] = 0;
+ foreach ($members as $member) {
+ foreach ($out[$member] ?? [] as $target) {
+ $other = $components->membership[$target];
+ if ($other !== $index) {
+ $depth[$index] = max($depth[$index], $depth[$other] + 1);
+ } else {
+ $cycles[] = ['from' => $member, 'to' => $target];
+ }
}
- $deepest = max($deepest, $walk($next, $path) + 1);
}
-
- return $depth[$node] = $deepest;
- };
-
- foreach ($ids as $id) {
- $walk($id, []);
}
-
- // Depth counts how far a node's longest chain of dependencies runs; the drawing wants the opposite,
- // with dependents on top. Flip it so level 0 is what nothing depends on.
$max = $depth === [] ? 0 : max($depth);
$levels = [];
- foreach ($depth as $id => $value) {
- $levels[$id] = $max - $value;
+ foreach ($components->membership as $id => $group) {
+ $levels[$id] = $max - $depth[$group];
+ }
+
+ return [$levels, $cycles];
+ }
+
+ /**
+ * @return array{out: array>, in: array>} */
+ public function adjacency(): array
+ {
+ $out = $in = [];
+ foreach ($this->edges as $edge) {
+ $out[$edge['from']][] = $edge['to'];
+ $in[$edge['to']][] = $edge['from'];
}
- $seen = [];
- $unique = [];
- foreach ($cycles as $cycle) {
- $key = $cycle['from'].'>'.$cycle['to'];
- if (! isset($seen[$key])) {
- $seen[$key] = true;
- $unique[] = $cycle;
+ return ['out' => $out, 'in' => $in];
+ }
+
+ /** Complete cyclic components, rather than arbitrary closing pairs.
+ * @return list> */
+ public function components(): array
+ {
+ $self = [];
+ foreach ($this->edges as $edge) {
+ if ($edge['from'] === $edge['to']) {
+ $self[$edge['from']] = true;
}
}
- return [$levels, $unique];
+ return array_values(array_filter(
+ GraphComponents::of(array_column($this->nodes, 'id'), $this->adjacency()['out'])->groups,
+ static fn (array $group): bool => count($group) > 1 || isset($self[$group[0]]),
+ ));
}
}
diff --git a/packages/admin/src/BeanGraphIndex.php b/packages/admin/src/BeanGraphIndex.php
index e43f11ec..c1f801cf 100644
--- a/packages/admin/src/BeanGraphIndex.php
+++ b/packages/admin/src/BeanGraphIndex.php
@@ -25,12 +25,42 @@ final class BeanGraphIndex
/** @var array interface or produced type => the node id that satisfies it */
private array $satisfiedBy = [];
+ /** @var array */
+ private array $ambiguous = [];
+
/** @var list, type: string}> */
private array $pending = [];
/** @var array how many factory methods produce each type */
private array $producerCount = [];
+ /**
+ * @param array $rows
+ * @param array $configProperties
+ */
+ public function countProducers(array $rows, array $configProperties = []): void
+ {
+ $classes = [];
+ foreach ($rows as $row) {
+ if (is_array($row) && is_string($row['class'] ?? null)) {
+ $classes[$row['class']] = true;
+ }
+ }
+ foreach ($configProperties as $class => $row) {
+ if (is_array($row) && ($row['bound'] ?? true) !== false && is_string($row['class'] ?? $class)) {
+ $classes[is_string($row['class'] ?? null) ? $row['class'] : (string) $class] = true;
+ }
+ }
+ foreach ($rows as $row) {
+ if (! is_array($row)) {
+ continue;
+ }
+ foreach ($this->producers($row['produces'] ?? null) as $produced) {
+ $this->producerCount[$produced['type']] = ($this->producerCount[$produced['type']] ?? (isset($classes[$produced['type']]) ? 1 : 0)) + 1;
+ }
+ }
+ }
+
/**
* @param array $row
*/
@@ -59,10 +89,6 @@ public function addComponent(array $row): void
'type' => BeanGraph::EDGE_INJECTS,
];
- foreach ($this->producers($row['produces'] ?? null) as $produced) {
- $this->producerCount[$produced['type']] = ($this->producerCount[$produced['type']] ?? 0) + 1;
- }
-
foreach ($this->producers($row['produces'] ?? null) as $produced) {
$this->addBean($class, $produced);
}
@@ -101,14 +127,17 @@ private function addBean(string $declaring, array $produced): void
'namespace' => rtrim(Format::namespaceOf($produced['type']), '\\'),
'kind' => BeanGraph::KIND_BEAN,
'stereotype' => 'bean',
- 'scope' => 'Singleton',
+ 'scope' => '',
'detail' => Format::shortClass($declaring).'::'.$produced['method'].'()',
]);
- // The produced type resolves to this node. With competitors, first-writer-wins gives the bare type a
- // stable owner while each competitor keeps its own node — the same shape the container itself has,
- // where the type key aliases the #[Primary] winner and every candidate stays reachable by name.
- $this->satisfy($produced['type'], $id);
+ // Without primary/qualifier metadata the catalogue cannot identify a contested winner.
+ if (! $contested) {
+ $this->satisfy($produced['type'], $id);
+ } else {
+ $this->ambiguous[$produced['type']] = true;
+ unset($this->satisfiedBy[$produced['type']]);
+ }
$this->pending[] = ['from' => $declaring, 'dependencies' => [$id], 'type' => BeanGraph::EDGE_PRODUCES];
$this->pending[] = ['from' => $id, 'dependencies' => $produced['dependencies'], 'type' => BeanGraph::EDGE_INJECTS];
@@ -151,11 +180,7 @@ public function edges(): array
continue;
}
- if ($target === $entry['from']) {
- continue;
- }
-
- $key = $entry['from'].'>'.$target.'>'.$entry['type'];
+ $key = $entry['from']."\0".$target."\0".$entry['type']."\0".$dependency;
if (isset($seen[$key])) {
continue;
}
@@ -175,13 +200,26 @@ public function edges(): array
private function resolve(string $type): ?string
{
+ if (isset($this->ambiguous[$type])) {
+ return null;
+ }
+
return isset($this->nodes[$type]) ? $type : ($this->satisfiedBy[$type] ?? null);
}
- /** First writer wins, so the same application always draws the same graph. */
+ /** The projection lacks qualifiers and primary metadata: never invent a winner. */
private function satisfy(string $type, string $nodeId): void
{
- $this->satisfiedBy[$type] ??= $nodeId;
+ if (isset($this->ambiguous[$type])) {
+ return;
+ }
+ if (isset($this->satisfiedBy[$type]) && $this->satisfiedBy[$type] !== $nodeId) {
+ unset($this->satisfiedBy[$type]);
+ $this->ambiguous[$type] = true;
+
+ return;
+ }
+ $this->satisfiedBy[$type] = $nodeId;
}
/**
diff --git a/packages/admin/src/BeanGraphSettings.php b/packages/admin/src/BeanGraphSettings.php
new file mode 100644
index 00000000..254ff5f5
--- /dev/null
+++ b/packages/admin/src/BeanGraphSettings.php
@@ -0,0 +1,35 @@
+int('firefly.admin.graph.focus.depth', 2))),
+ maxRows: min(60, max(4, $config->int('firefly.admin.graph.focus.max-rows', 16))),
+ maxNodes: min(300, max(8, $config->int('firefly.admin.graph.focus.max-nodes', 72))),
+ maxPaths: min(10, max(0, $config->int('firefly.admin.graph.focus.max-paths', 3))),
+ pageSize: min(500, max(10, $config->int('firefly.admin.graph.focus.page-size', 50))),
+ starters: min(50, max(1, $config->int('firefly.admin.graph.starters', 12))),
+ moduleMaxNodes: min(200, max(0, $config->int('firefly.admin.graph.modules.max-nodes', 40))),
+ beansPageSize: min(500, max(10, $config->int('firefly.admin.beans.page-size', 50))),
+ );
+ }
+}
diff --git a/packages/admin/src/BeanModules.php b/packages/admin/src/BeanModules.php
new file mode 100644
index 00000000..1154914c
--- /dev/null
+++ b/packages/admin/src/BeanModules.php
@@ -0,0 +1,132 @@
+ $nodes
+ * @param list $edges
+ * @param list> $cycles
+ */
+ private function __construct(public array $nodes, public array $edges, public array $cycles) {}
+
+ public static function fromGraph(BeanGraph $graph, bool $produces = false): self
+ {
+ $nodes = $aggregated = $used = $out = [];
+ foreach ($graph->modules() as $module) {
+ $leaf = Format::shortClass($module);
+ $base = substr($leaf, 0, 2);
+ $used[$base] = ($used[$base] ?? 0) + 1;
+ $nodes[$module] = ['count' => 0, 'sigil' => $base.($used[$base] > 1 ? $used[$base] : ''), 'hue' => (count($nodes) * 137) % 360];
+ }
+ foreach ($graph->nodes as $node) {
+ $module = BeanGraph::moduleOf($node['id']);
+ if (isset($nodes[$module])) {
+ $nodes[$module]['count']++;
+ }
+ }
+ foreach ($graph->edges as $edge) {
+ if (! $produces && $edge['type'] === BeanGraph::EDGE_PRODUCES) {
+ continue;
+ }
+ $from = BeanGraph::moduleOf($edge['from']);
+ $to = BeanGraph::moduleOf($edge['to']);
+ if ($from === $to) {
+ continue;
+ }
+ $key = $from.'>'.$to;
+ $aggregated[$key] ??= ['from' => $from, 'to' => $to, 'weight' => 0, 'targets' => [], 'interfaces' => [], 'concrete' => 0];
+ $aggregated[$key]['weight']++;
+ $aggregated[$key]['targets'][$edge['to']] = true;
+ if ($edge['via'] !== null) {
+ $aggregated[$key]['interfaces'][$edge['via']] = true;
+ } else {
+ $aggregated[$key]['concrete']++;
+ }
+ $out[$from][] = $to;
+ }
+ $edges = [];
+ foreach ($aggregated as $edge) {
+ $edges[] = ['from' => $edge['from'], 'to' => $edge['to'], 'weight' => $edge['weight'], 'beans' => count($edge['targets']), 'via' => count($edge['interfaces']), 'concrete' => $edge['concrete']];
+ }
+
+ return new self($nodes, $edges, array_values(array_filter(GraphComponents::of(array_keys($nodes), $out)->groups, static fn (array $group): bool => count($group) > 1)));
+ }
+
+ /** A module's own beans plus one boundary port per foreign module. */
+ public static function scope(BeanGraph $graph, string $module): BeanGraph
+ {
+ $nodes = [];
+ foreach ($graph->nodes as $node) {
+ if (BeanGraph::moduleOf($node['id']) === $module) {
+ $nodes[$node['id']] = $node;
+ }
+ }
+ $edges = [];
+ foreach ($graph->edges as $edge) {
+ $from = BeanGraph::moduleOf($edge['from']);
+ $to = BeanGraph::moduleOf($edge['to']);
+ if ($from !== $module && $to !== $module) {
+ continue;
+ }
+ foreach (['from' => $from, 'to' => $to] as $side => $foreign) {
+ if ($foreign !== $module) {
+ $id = 'module:'.$foreign;
+ $edge[$side] = $id;
+ $nodes[$id] ??= ['id' => $id, 'label' => $foreign, 'namespace' => $foreign, 'kind' => 'module', 'stereotype' => '', 'scope' => '', 'detail' => 'Boundary port', 'level' => 0, 'in' => 0, 'out' => 0];
+ }
+ }
+ $edges[$edge['from']."\0".$edge['to']."\0".$edge['type']] = $edge;
+ }
+
+ foreach ($edges as $edge) {
+ if ($nodes[$edge['from']]['kind'] === 'module') {
+ $nodes[$edge['from']]['out']++;
+ }
+ if ($nodes[$edge['to']]['kind'] === 'module') {
+ $nodes[$edge['to']]['in']++;
+ }
+ }
+
+ return new BeanGraph(array_values($nodes), array_values($edges), [], []);
+ }
+
+ /**
+ * @return list */
+ public static function exclusive(BeanGraph $graph, string $module): array
+ {
+ $adjacency = $graph->adjacency()['out'];
+ $own = $others = [];
+ foreach ($graph->nodes as $node) {
+ if (BeanGraph::moduleOf($node['id']) === $module) {
+ $own[] = $node['id'];
+ } else {
+ $others[] = $node['id'];
+ }
+ }
+
+ return array_values(array_diff(self::reachable($own, $adjacency), self::reachable($others, $adjacency)));
+ }
+
+ /**
+ * @param list $queue
+ * @param array> $out
+ * @return list */
+ private static function reachable(array $queue, array $out): array
+ {
+ $seen = array_fill_keys($queue, true);
+ for ($i = 0; isset($queue[$i]); $i++) {
+ foreach ($out[$queue[$i]] ?? [] as $next) {
+ if (! isset($seen[$next])) {
+ $seen[$next] = true;
+ $queue[] = $next;
+ }
+ }
+ }
+
+ return array_keys($seen);
+ }
+}
diff --git a/packages/admin/src/BeanNeighbourhood.php b/packages/admin/src/BeanNeighbourhood.php
new file mode 100644
index 00000000..f23644ce
--- /dev/null
+++ b/packages/admin/src/BeanNeighbourhood.php
@@ -0,0 +1,142 @@
+ $positions
+ * @param list}> $overflow
+ * @param list> $paths
+ * @param list $columns
+ */
+ private function __construct(
+ public array $positions,
+ public array $overflow,
+ public array $paths,
+ public array $columns,
+ public int $width,
+ public int $height,
+ ) {}
+
+ public static function around(BeanGraph $graph, string $focus, int $depth, string $direction, BeanGraphSettings $settings): ?self
+ {
+ $nodes = array_column($graph->nodes, null, 'id');
+ if (! isset($nodes[$focus])) {
+ return null;
+ }
+ $depth = min(4, max(1, $depth));
+ $adjacency = $graph->adjacency();
+ $columns = range($direction === 'out' ? 0 : -$depth, $direction === 'in' ? 0 : $depth);
+ $placed = [$focus => 0];
+ $byColumn = [0 => [$focus]];
+ $frontiers = ['in' => [$focus], 'out' => [$focus]];
+ $overflow = [];
+ for ($hop = 1; $hop <= $depth; $hop++) {
+ foreach (['in', 'out'] as $side) {
+ if ($direction !== 'both' && $direction !== $side) {
+ continue;
+ }
+ $column = $side === 'in' ? -$hop : $hop;
+ $queues = [];
+ foreach ($frontiers[$side] as $source) {
+ $neighbors = array_values(array_unique($adjacency[$side][$source] ?? []));
+ usort($neighbors, static fn (string $a, string $b): int => [$nodes[$b]['in'] + $nodes[$b]['out'], $nodes[$a]['label'], $a] <=> [$nodes[$a]['in'] + $nodes[$a]['out'], $nodes[$b]['label'], $b]);
+ $queues[$source] = $neighbors;
+ }
+ $selected = [];
+ $offset = 0;
+ do {
+ $hasNext = false;
+ foreach ($queues as $queue) {
+ if (! isset($queue[$offset])) {
+ continue;
+ }
+ $hasNext = true;
+ $id = $queue[$offset];
+ if (! isset($placed[$id]) && count($selected) < $settings->maxRows && count($placed) < $settings->maxNodes) {
+ $placed[$id] = $column;
+ $selected[] = $id;
+ }
+ }
+ $offset++;
+ } while ($hasNext);
+ // Order by the mean position of adjacent already-placed parents, then exact identity.
+ $ranks = array_flip($frontiers[$side]);
+ $score = [];
+ foreach ($selected as $id) {
+ $parents = $adjacency[$side === 'in' ? 'out' : 'in'][$id] ?? [];
+ $values = [];
+ foreach ($parents as $parent) {
+ if (isset($ranks[$parent])) {
+ $values[] = $ranks[$parent];
+ }
+ }
+ sort($values);
+ $score[$id] = $values === [] ? 0 : $values[intdiv(count($values), 2)];
+ }
+ usort($selected, static fn (string $a, string $b): int => [$score[$a], $nodes[$a]['label'], $a] <=> [$score[$b], $nodes[$b]['label'], $b]);
+ $byColumn[$column] = $frontiers[$side] = $selected;
+ foreach ($queues as $source => $queue) {
+ $hidden = array_values(array_filter($queue, static fn (string $id): bool => ! isset($placed[$id])));
+ if ($hidden !== []) {
+ $overflow[] = ['source' => $source, 'direction' => $side, 'count' => count($hidden), 'ids' => $hidden];
+ }
+ }
+ }
+ }
+ $finalOverflow = [];
+ foreach ($overflow as $entry) {
+ $ids = array_values(array_filter($entry['ids'], static fn (string $id): bool => ! isset($placed[$id])));
+ if ($ids !== []) {
+ $finalOverflow[] = [...$entry, 'ids' => $ids, 'count' => count($ids)];
+ }
+ }
+ $rows = max(1, ...array_map('count', $byColumn));
+ $height = $rows * 46 - 8;
+ $positions = [];
+ foreach ($columns as $index => $column) {
+ $members = $byColumn[$column] ?? [];
+ $top = intdiv(($rows - count($members)) * 46, 2);
+ foreach ($members as $row => $id) {
+ $positions[$id] = ['x' => $index * 204, 'y' => $top + $row * 46, 'column' => $column, 'row' => $row];
+ }
+ }
+
+ return new self($positions, $finalOverflow, self::rootPaths($focus, $adjacency['in'], $settings->maxPaths), $columns, count($columns) * 204 - 28, $height);
+ }
+
+ /**
+ * @param array> $in
+ * @return list> */
+ private static function rootPaths(string $focus, array $in, int $limit): array
+ {
+ foreach ($in as &$parents) {
+ sort($parents, SORT_STRING);
+ }
+ unset($parents);
+ $queue = [$focus];
+ $toward = [$focus => null];
+ $paths = [];
+ for ($i = 0; isset($queue[$i]) && count($paths) < $limit; $i++) {
+ $id = $queue[$i];
+ if (($in[$id] ?? []) === []) {
+ $path = [];
+ for ($next = $id; $next !== null; $next = $toward[$next]) {
+ $path[] = $next;
+ }
+ $paths[] = $path;
+ }
+ foreach ($in[$id] ?? [] as $parent) {
+ if (! array_key_exists($parent, $toward)) {
+ $toward[$parent] = $id;
+ $queue[] = $parent;
+ }
+ }
+ }
+
+ return $paths;
+ }
+}
diff --git a/packages/admin/src/Data/DataBrowserSettings.php b/packages/admin/src/Data/DataBrowserSettings.php
index 7734f88e..16be978a 100644
--- a/packages/admin/src/Data/DataBrowserSettings.php
+++ b/packages/admin/src/Data/DataBrowserSettings.php
@@ -91,7 +91,17 @@ public function allows(string $slug): bool
return ! in_array($slug, $this->excluded, true);
}
- /** Clamp a caller-supplied page size into [1, maxPageSize]; null means "use the configured default". */
+ /**
+ * Clamp a caller-supplied page size into [1, maxPageSize]; null means "use the configured default".
+ *
+ * THE DASHBOARD DOES NOT ARRIVE HERE WITH A SURPRISE. Its listing query is parsed against
+ * `Table\TableSettings::boundedBy($this->pageSize, $this->maxPageSize)`, so the size it hands `list()`
+ * is already inside both bounds and this returns it unchanged — which is exactly what lets the
+ * rows-per-page control offer the sizes the listing can actually serve, and what lets `page-size` decide
+ * the default there rather than being shadowed by a size the page states on every call. Both bounds
+ * still bite for a direct `DataBrowser::list()` call, which has no query to have been parsed against
+ * them and may ask for anything.
+ */
public function clampPageSize(?int $requested): int
{
if ($requested === null) {
diff --git a/packages/admin/src/Data/DataFilter.php b/packages/admin/src/Data/DataFilter.php
index ca68a90e..60fbe0ca 100644
--- a/packages/admin/src/Data/DataFilter.php
+++ b/packages/admin/src/Data/DataFilter.php
@@ -78,29 +78,46 @@ public function label(): string
}
/**
- * The query-string form of a list of filters, so every link that must preserve them is built from one
- * place. A single equality keeps the short `fk`/`fv` spelling that relation links use.
+ * This filter set as query PARAMETERS, which is the form every link builder wants.
+ *
+ * A LINK CARRIES A FILTER AS DATA, NOT AS A STRING IT WAS HANDED. `ListingQuery` is given this array as
+ * the parameters it must not lose, and it writes them out with `http_build_query` alongside its own —
+ * which is what lets a sort link, a page link and a search form each rebuild the whole URL from parsed
+ * values rather than concatenating someone else's fragment onto their own.
*
* @param list $filters
+ * @return array>
*/
- public static function toQuery(array $filters): string
+ public static function toParameters(array $filters): array
{
if ($filters === []) {
- return '';
+ return [];
}
+ // TWO SPELLINGS, ONE MEANING. `fk`/`fv` is a single equality and is what every relation link
+ // produces — short enough to read in a status bar. Both are validated identically downstream.
if (count($filters) === 1 && $filters[0]->operator === self::EQ) {
- return 'fk='.urlencode($filters[0]->column).'&fv='.urlencode($filters[0]->value);
+ return ['fk' => $filters[0]->column, 'fv' => $filters[0]->value];
}
- $parts = [];
- foreach ($filters as $filter) {
- $parts[] = 'fc[]='.urlencode($filter->column)
- .'&fo[]='.urlencode($filter->operator)
- .'&fv[]='.urlencode($filter->value);
- }
+ return [
+ 'fc' => array_map(static fn (self $filter): string => $filter->column, $filters),
+ 'fo' => array_map(static fn (self $filter): string => $filter->operator, $filters),
+ 'fv' => array_map(static fn (self $filter): string => $filter->value, $filters),
+ ];
+ }
- return implode('&', $parts);
+ /**
+ * The query-string form, for the one caller that still wants a string.
+ *
+ * Built by http_build_query rather than by concatenating urlencode() calls: the hand-built version was
+ * correct and was one edit away from not being, which is exactly the class of bug this wave is removing.
+ *
+ * @param list $filters
+ */
+ public static function toQuery(array $filters): string
+ {
+ return http_build_query(self::toParameters($filters));
}
/** A one-line description of what this filter narrows to, for the banner above a filtered listing. */
diff --git a/packages/admin/src/Data/DataQueryEngine.php b/packages/admin/src/Data/DataQueryEngine.php
index e52280aa..2284d743 100644
--- a/packages/admin/src/Data/DataQueryEngine.php
+++ b/packages/admin/src/Data/DataQueryEngine.php
@@ -7,6 +7,7 @@
use BackedEnum;
use DateTimeInterface;
use Firefly\Actuator\Introspection\SensitiveValueMasker;
+use Firefly\Admin\RowComparator;
use Firefly\Data\Repository\CrudRepository;
use Firefly\Data\Repository\EloquentRepository;
use Firefly\Data\Repository\Page;
@@ -181,7 +182,7 @@ private function fetch(
?string $term,
array $filters = [],
): array {
- $pageable = new Pageable($page, $perPage, $this->sort($sort, $direction));
+ $pageable = new Pageable($page, $perPage, $this->sort($sort, $direction, $schema));
if (($term !== null || $filters !== []) && $repository instanceof EloquentRepository) {
$specifications = [];
@@ -327,10 +328,60 @@ private function fetchInPhp(
}
if ($sort !== null) {
- usort($matched, function (array $a, array $b) use ($sort, $direction): int {
- $comparison = $this->compare($a['values'][$sort] ?? null, $b['values'][$sort] ?? null);
+ // The same ordering the actuator listings use, from the same class — a sort drifts as quietly as
+ // a filter does, and the drift would show as one column header meaning two different orders on
+ // two pages of one dashboard. It is asked for the COLUMN, once, rather than pair by pair: a
+ // column that mixes numbers with anything else has no consistent pairwise answer (see
+ // RowComparator::forColumn()), and an inconsistent comparison here reorders the rows that
+ // straddle a page boundary between one request and the next. Emptiness is ranked OUTSIDE the
+ // direction flip: an em-dash is the absence of a value rather than a value that sorts low, so it
+ // stays last under `desc` too.
+ $values = array_map(static fn (array $row): mixed => $row['values'][$sort] ?? null, $matched);
+ $compare = $sort === $schema->identifier
+ ? RowComparator::forIdentity($values)
+ : RowComparator::forColumn($values);
+
+ // The same tiebreak the SQL paths get, for the same reason: `usort` is stable in PHP 8, but the
+ // ARRAY it is stabilising is rebuilt from the repository on every request, so stability of the
+ // sort is not stability of the page boundary. See sort() above. Like the empty rank above it,
+ // and for the same reason, it sits OUTSIDE the direction flip: an identity is not a second
+ // ordering. It is appended only when the identifier is not already the sort column, for the
+ // same reason `ORDER BY id DESC, id ASC` is not emitted.
+ //
+ // THE TIEBREAK IS A COLUMN TOO, AND IS CHOSEN THE SAME WAY — `forColumn()` over everything the
+ // identifier column holds, never `compare()` pair by pair. `Table\InMemoryListing::order()`
+ // does exactly this beside it, and RowComparator says why: a per-pair choice between the
+ // arithmetic and the natural comparison can close a cycle (`1.10 < 1.9 < 1.9-beta < 1.10` on a
+ // column of versions), and an ordering that is not a strict weak ordering entitles `usort()` to
+ // answer anything at all. An inconsistent tiebreak breaks the whole ordering just as thoroughly
+ // as an inconsistent primary, which is the instability this branch exists to remove.
+ $tiebreak = $schema->identifier;
+ $breakTie = $tiebreak === null || $tiebreak === $sort ? null : RowComparator::forIdentity(array_map(
+ static fn (array $row): mixed => $row['values'][$tiebreak] ?? null,
+ $matched,
+ ));
+
+ usort($matched, static function (array $a, array $b) use ($sort, $direction, $compare, $tiebreak, $breakTie): int {
+ $left = $a['values'][$sort] ?? null;
+ $right = $b['values'][$sort] ?? null;
+
+ $rank = RowComparator::rankEmpty($left, $right);
+
+ if ($rank !== 0) {
+ return $rank;
+ }
+
+ $comparison = $compare($left, $right);
- return $direction === 'desc' ? -$comparison : $comparison;
+ if ($direction === 'desc') {
+ $comparison = -$comparison;
+ }
+
+ // `$breakTie` is null for exactly the two cases that have no tiebreak to apply — no
+ // identifier, or an identifier that IS the sort column — so testing it tests both.
+ return $comparison !== 0 || $breakTie === null
+ ? $comparison
+ : $breakTie($a['values'][$tiebreak] ?? null, $b['values'][$tiebreak] ?? null);
});
}
@@ -375,8 +426,10 @@ private function passes(array $values, array $filters): bool
DataFilter::NE => $string !== $filter->value,
DataFilter::CONTAINS => $string !== null && str_contains(mb_strtolower($string), mb_strtolower($filter->value)),
DataFilter::STARTS => $string !== null && str_starts_with(mb_strtolower($string), mb_strtolower($filter->value)),
- DataFilter::GT => $string !== null && $this->compare($value, $filter->value) > 0,
- DataFilter::LT => $string !== null && $this->compare($value, $filter->value) < 0,
+ // The SCALAR STRING, not the raw value: see compare() for why a bool must reach it as the
+ // `'1'`/`''` the driver would have bound and not as the word the listing renders.
+ DataFilter::GT => $string !== null && $this->compare($string, $filter->value) > 0,
+ DataFilter::LT => $string !== null && $this->compare($string, $filter->value) < 0,
DataFilter::NULL => $value === null,
DataFilter::NOT_NULL => $value !== null,
default => $string === $filter->value,
@@ -430,15 +483,38 @@ private function sortColumn(DataSchema $schema, ?string $requested): ?string
: null;
}
- private function sort(?string $column, string $direction): ?Sort
+ /**
+ * The ORDER BY a listing goes out with: what was asked for, then the identifier as a tiebreak.
+ *
+ * EVERY LISTING IS ORDERED, EVEN WHEN NOBODY ASKED — that much was already true, and it is why
+ * sortColumn() falls back to the identifier. What was missing is the second half. Ordering by a column
+ * with duplicate values leaves the tied rows in whatever order the engine finds convenient, and it is
+ * allowed to find a different one convenient for the query behind page 1 and the query behind page 2:
+ * a row is then returned on both and another on neither, and the operator reads a table that is missing
+ * records which are really there. `ORDER BY status DESC, id ASC` has no such freedom.
+ *
+ * THE TIEBREAK IS ALWAYS ASCENDING. It is an identity, not a second ordering — mirroring it with the
+ * primary direction would make the order WITHIN a tie depend on the direction it is breaking the tie
+ * inside, which is the same instability with extra steps. And it is appended only when the identifier
+ * is not already the sort column, because `ORDER BY id DESC, id ASC` is at best noise and at worst an
+ * index the planner declines to use.
+ */
+ private function sort(?string $column, string $direction, DataSchema $schema): ?Sort
{
if ($column === null) {
return null;
}
$sort = Sort::by($column);
+ if ($direction === 'desc') {
+ $sort = $sort->descending();
+ }
+
+ $identifier = $schema->identifier;
- return $direction === 'desc' ? $sort->descending() : $sort;
+ return $identifier !== null && $identifier !== $column && in_array($identifier, $schema->sortable(), true)
+ ? $sort->and(Sort::by($identifier))
+ : $sort;
}
private function term(?string $search): ?string
@@ -543,24 +619,26 @@ private function truncate(string $value, ?int $limit): string
return mb_substr($value, 0, $limit).self::ELLIPSIS;
}
- /** Null-last ordering, so a nullable column does not sort its empties into the middle of the values. */
- private function compare(mixed $a, mixed $b): int
+ /**
+ * How `greater than` and `less than` compare on the unpaged path.
+ *
+ * IT IS GIVEN THE CELL'S SCALAR STRING, NEVER THE RAW VALUE, and the difference is not cosmetic. The
+ * paged sibling of this predicate is `where(col, '>', ?)` in SQL: the driver binds a bool as `1`/`0`, so
+ * `pinned > 0` selects the pinned rows. RowComparator renders a bool for a READER — `true`/`false` — and
+ * a word is not numeric, so the pair would fall to the natural-text comparison where `t` and `f` sort
+ * after every digit: `greater than 0` would match EVERY row and `less than 1` none, on a repository that
+ * cannot page, while the identical filter over a pageable resource answered correctly. `(string) true`
+ * is `'1'` and `(string) false` is `''`, which are the shapes the binding has, so passing the string
+ * keeps one filter meaning one thing on both paths. That is the same drift the search predicate above
+ * warns about, arriving through the operand rather than through a second implementation.
+ *
+ * It is RowComparator's value comparison and nothing else — no empty-last rank. In SQL the empty string
+ * is simply the smallest string, and ranking empties last here would be the same drift again. Where
+ * empties go is a question about a LISTING's order, which is asked in the sort, not here.
+ */
+ private function compare(string $a, string $b): int
{
- if ($a === null && $b === null) {
- return 0;
- }
- if ($a === null) {
- return 1;
- }
- if ($b === null) {
- return -1;
- }
-
- if (is_scalar($a) && is_scalar($b)) {
- return is_numeric($a) && is_numeric($b) ? ($a + 0) <=> ($b + 0) : strnatcasecmp((string) $a, (string) $b);
- }
-
- return 0;
+ return RowComparator::compare($a, $b);
}
/**
diff --git a/packages/admin/src/ExplorerQuery.php b/packages/admin/src/ExplorerQuery.php
new file mode 100644
index 00000000..304b0cc1
--- /dev/null
+++ b/packages/admin/src/ExplorerQuery.php
@@ -0,0 +1,60 @@
+ $parameters */
+ public function __construct(public string $path, public array $parameters, public int $depth, public string $direction) {}
+
+ public static function fromRequest(Request $request, string $path, BeanGraphSettings $settings): self
+ {
+ $parameters = [];
+ foreach ($request->query() as $key => $value) {
+ if (is_string($key) && is_string($value) && strlen($value) <= 1024) {
+ $parameters[$key] = $value;
+ }
+ }
+ $raw = $parameters['depth'] ?? '';
+ $depth = ctype_digit($raw) ? min(4, max(1, (int) $raw)) : $settings->depth;
+ $direction = in_array($parameters['dir'] ?? '', ['in', 'out', 'both'], true) ? $parameters['dir'] : 'both';
+ $parameters['depth'] = (string) $depth;
+ $parameters['dir'] = $direction;
+
+ return new self($path, $parameters, $depth, $direction);
+ }
+
+ public function get(string $key): string
+ {
+ return $this->parameters[$key] ?? '';
+ }
+
+ /** @param array $changes */
+ public function url(array $changes = []): string
+ {
+ $parameters = array_filter([...$this->parameters, ...$changes], static fn (string|int|null $value): bool => $value !== null && $value !== '');
+
+ return $this->path.($parameters === [] ? '' : '?'.http_build_query($parameters));
+ }
+
+ /** Parameters owned by other controls only.
+ * @return array */
+ public function carried(string $qualifier): array
+ {
+ $parameters = $this->parameters;
+ foreach (['page', 'size', 'sort', 'dir', 'q'] as $key) {
+ unset($parameters[$qualifier.'_'.$key]);
+ }
+
+ return $parameters;
+ }
+
+ public function bean(string $id): string
+ {
+ return $this->url(['bean' => $id, 'module' => null, 'q' => null, 'neighbor' => null, 'rel' => null, 'in_page' => null, 'out_page' => null]);
+ }
+}
diff --git a/packages/admin/src/Format.php b/packages/admin/src/Format.php
index a06b01ce..11d48653 100644
--- a/packages/admin/src/Format.php
+++ b/packages/admin/src/Format.php
@@ -85,7 +85,16 @@ public static function measurement(string $meterName, float $value): string
return self::count($value);
}
- /** "3 minutes ago" for a unix timestamp, or an ISO instant when it is older than a day. */
+ /**
+ * "3 minutes ago" for a unix timestamp, or a dated instant when it is older than a day.
+ *
+ * THE WIDEST THING THIS EMITS IS SIXTEEN CHARACTERS, not the six of `2h ago`, and a column sized for
+ * the age alphabet clips the date one. Past the 86400-second arm an age stops being informative — "37h
+ * ago" is a number the reader has to do arithmetic on — so the last arm gives the instant itself, and
+ * `2026-09-22 20:49` is what a `Stamp` column has to be wide enough to draw. See
+ * AdminAction::data()'s HTTP listing, whose When column is sized from this method's alphabet, and
+ * FormatStampTest, which pins both widths so a future arm cannot widen one without failing.
+ */
public static function since(float $timestamp, float $now): string
{
$delta = max(0.0, $now - $timestamp);
@@ -99,6 +108,21 @@ public static function since(float $timestamp, float $now): string
};
}
+ /**
+ * The full instant behind a `since()` age — `2026-09-22 20:49:26`, the nineteen characters
+ * `TableColumn::stamp()` takes as its default width.
+ *
+ * This is what a stamp cell carries on its `title`, and it exists because an age is LOSSY in both
+ * directions: `2h ago` does not say which two hours, and the dated arm rounds the seconds off. A reader
+ * correlating an exchange against a log line needs the instant, and a cell whose text is clipped by its
+ * column needs somewhere to recover the value from — the same contract `t-token`, `t-path` and `t-line`
+ * already keep with the full value on the title.
+ */
+ public static function instant(float $timestamp): string
+ {
+ return date('Y-m-d H:i:s', (int) $timestamp);
+ }
+
/** The share one value takes of a maximum, clamped to 0..100 for a bar width. */
public static function percent(float $value, float $max): float
{
@@ -143,18 +167,40 @@ public static function elide(string $value, int $max): string
return strlen($value) <= $max ? $value : '…'.substr($value, -($max - 1));
}
+ /**
+ * The half of a qualified name that IDENTIFIES it: `OrderController`, `store`.
+ *
+ * Parameterised on the separator because the dashboard qualifies four different things by three
+ * different characters — a class by `\`, a config key and a meter name by `.` — and the rendering is
+ * identical in all of them: the leaf on top, the stem dim underneath, and the stem is the half that may
+ * be elided when the column is narrow.
+ */
+ public static function leafOf(string $value, string $separator = '\\'): string
+ {
+ $position = strrpos($value, $separator);
+
+ return $position === false ? $value : substr($value, $position + strlen($separator));
+ }
+
+ /** The half that LOCATES it: `App\Http\Controllers`, `firefly.observability.metrics`. */
+ public static function stemOf(string $value, string $separator = '\\'): string
+ {
+ $position = strrpos($value, $separator);
+
+ return $position === false ? '' : substr($value, 0, $position);
+ }
+
/** A short, readable class name with its namespace kept as a separate, dimmable prefix. */
public static function shortClass(string $fqcn): string
{
- $position = strrpos($fqcn, '\\');
-
- return $position === false ? $fqcn : substr($fqcn, $position + 1);
+ return self::leafOf($fqcn);
}
+ /** The namespace WITH its trailing separator — the shape the bean graph and the entity map print. */
public static function namespaceOf(string $fqcn): string
{
- $position = strrpos($fqcn, '\\');
+ $stem = self::stemOf($fqcn);
- return $position === false ? '' : substr($fqcn, 0, $position + 1);
+ return $stem === '' ? '' : $stem.'\\';
}
}
diff --git a/packages/admin/src/GraphComponents.php b/packages/admin/src/GraphComponents.php
new file mode 100644
index 00000000..2fad9197
--- /dev/null
+++ b/packages/admin/src/GraphComponents.php
@@ -0,0 +1,74 @@
+> $groups
+ * @param array $membership
+ */
+ private function __construct(public array $groups, public array $membership) {}
+
+ /**
+ * @param list $ids
+ * @param array> $out
+ */
+ public static function of(array $ids, array $out): self
+ {
+ $index = $low = $active = $membership = [];
+ $stack = $groups = [];
+ $next = 0;
+ foreach ($ids as $root) {
+ if (isset($index[$root])) {
+ continue;
+ }
+ $frames = [[$root, 0, null]];
+ while ($frames !== []) {
+ $top = count($frames) - 1;
+ [$node, $offset, $parent] = $frames[$top];
+ if (! isset($index[$node])) {
+ $index[$node] = $low[$node] = $next++;
+ $stack[] = $node;
+ $active[$node] = true;
+ }
+ $neighbors = $out[$node] ?? [];
+ if ($offset < count($neighbors)) {
+ $target = $neighbors[$offset];
+ $frames[$top][1]++;
+ if (! isset($index[$target])) {
+ $frames[] = [$target, 0, $node];
+ } elseif (isset($active[$target])) {
+ $low[$node] = min($low[$node], $index[$target]);
+ }
+
+ continue;
+ }
+ array_pop($frames);
+ if ($parent !== null) {
+ $low[$parent] = min($low[$parent], $low[$node]);
+ }
+ if ($low[$node] === $index[$node]) {
+ $group = [];
+ do {
+ $member = array_pop($stack);
+ if ($member === null) {
+ break;
+ }
+ unset($active[$member]);
+ $membership[$member] = count($groups);
+ $group[] = $member;
+ } while ($member !== $node);
+ sort($group);
+ $groups[] = $group;
+ }
+ }
+ }
+
+ return new self($groups, $membership);
+ }
+}
diff --git a/packages/admin/src/Route/RouteBinding.php b/packages/admin/src/Route/RouteBinding.php
new file mode 100644
index 00000000..504b2f12
--- /dev/null
+++ b/packages/admin/src/Route/RouteBinding.php
@@ -0,0 +1,34 @@
+notFoundMessage = isset($plan['pattern'])
+ ? ($plan['notFoundMessage'] ?? ArgumentResolver::notFoundSentence($plan['name'])) : null;
+ }
+
+ public function supplied(): bool
+ {
+ // Resolver claims take precedence over every compiled kind, including query and service.
+ return $this->resolver !== null || $this->plan['kind'] === 'service';
+ }
+
+ public function defaultLabel(): string
+ {
+ $encoded = json_encode($this->plan['default'], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
+
+ return $encoded !== false ? $encoded : 'null';
+ }
+}
diff --git a/packages/admin/src/Route/RouteBodyNode.php b/packages/admin/src/Route/RouteBodyNode.php
new file mode 100644
index 00000000..b0e78be1
--- /dev/null
+++ b/packages/admin/src/Route/RouteBodyNode.php
@@ -0,0 +1,55 @@
+ $children */
+ public function __construct(public string $name, public ?string $type, public bool $list, public array $children = [], public ?string $note = null) {}
+
+ /** @return list */
+ public static function forBinding(RouteBinding $binding): array
+ {
+ $plan = $binding->plan;
+ $type = $plan['type'];
+ $shapes = $plan['dtos'] ?? [];
+ if ($type === null || ! isset($shapes[$type])) {
+ return array_map(static fn (string $name): self => new self($name, null, false), $plan['properties']);
+ }
+ $budget = 200;
+
+ return self::walk($type, $shapes, [], $budget);
+ }
+
+ /**
+ * @param array> $shapes
+ * @param list $path
+ * @return list
+ */
+ private static function walk(string $type, array $shapes, array $path, int &$budget): array
+ {
+ $path[] = $type;
+ $nodes = [];
+ foreach ($shapes[$type] ?? [] as $name => $property) {
+ if (--$budget < 0) {
+ $nodes[] = new self('More properties', null, false, note: 'Display limit reached');
+ break;
+ }
+ $class = $property['class'];
+ $note = match (true) {
+ $class !== null && in_array($class, $path, true) => 'Recursive reference',
+ count($path) >= 16 => 'Depth limit reached',
+ default => null,
+ };
+ $children = $class !== null && $note === null ? self::walk($class, $shapes, $path, $budget) : [];
+ $nodes[] = new self($name, $class, $property['list'], $children, $note);
+ }
+
+ return $nodes;
+ }
+}
diff --git a/packages/admin/src/Route/RouteDetail.php b/packages/admin/src/Route/RouteDetail.php
new file mode 100644
index 00000000..353ba23d
--- /dev/null
+++ b/packages/admin/src/Route/RouteDetail.php
@@ -0,0 +1,35 @@
+ $registrations
+ * @param list $caller
+ * @param list $injected
+ * @param list $failures
+ * @param list $siblings
+ */
+ public function __construct(
+ public RouteDescriptor $route,
+ public array $registrations,
+ public array $caller,
+ public array $injected,
+ public array $failures,
+ public array $siblings,
+ ) {}
+
+ /** @return list */
+ public function arguments(): array
+ {
+ $arguments = [...$this->caller, ...$this->injected];
+ usort($arguments, static fn (RouteBinding $a, RouteBinding $b): int => $a->position <=> $b->position);
+
+ return $arguments;
+ }
+}
diff --git a/packages/admin/src/Route/RouteInspector.php b/packages/admin/src/Route/RouteInspector.php
new file mode 100644
index 00000000..a7303512
--- /dev/null
+++ b/packages/admin/src/Route/RouteInspector.php
@@ -0,0 +1,141 @@
+httpMethod.' '.$route->path;
+ }
+
+ public function detail(string $key): ?RouteDetail
+ {
+ if (! $this->container->bound(RouteManifest::class)) {
+ return null;
+ }
+ $routes = $this->container->make(RouteManifest::class)->all();
+ $matches = array_values(array_filter($routes, static fn (RouteDescriptor $route): bool => self::keyOf($route) === $key));
+ if ($matches === []) {
+ return null;
+ }
+ // Laravel registers in manifest order: the final registration for a verb/path replaces earlier ones.
+ $route = $matches[array_key_last($matches)];
+ $resolvers = $this->container->bound(HandlerMethodArgumentResolvers::class)
+ ? $this->container->make(HandlerMethodArgumentResolvers::class) : null;
+ $caller = $injected = $failures = [];
+ foreach ($route->bindings as $position => $plan) {
+ // supports() only. resolve() can read a security context, instantiate services, or throw.
+ $resolver = $resolvers?->resolverFor($plan);
+ $binding = new RouteBinding($position + 1, $plan, $resolver === null ? null : $resolver::class);
+ if ($binding->supplied()) {
+ $injected[] = $binding;
+ } else {
+ $caller[] = $binding;
+ array_push($failures, ...$this->failures($binding));
+ }
+ }
+
+ return new RouteDetail($route, $matches, $caller, $injected, $failures, array_values(array_filter(
+ $routes, static fn (RouteDescriptor $sibling): bool => $sibling->controllerClass === $route->controllerClass && self::keyOf($sibling) !== $key,
+ )));
+ }
+
+ /** @return array{uri: string, name: string|null, domain: string|null, middleware: list, patterns: array}|null */
+ public function metadata(RouteDescriptor $descriptor): ?array
+ {
+ $router = $this->container->bound('router') ? $this->container->make('router') : null;
+ if (! $router instanceof Router) {
+ return null;
+ }
+ $uri = $descriptor->path === '/' ? '/' : trim($descriptor->path, '/');
+ foreach ($router->getRoutes()->getRoutes() as $route) {
+ // Manifest routes have no domain. A domain-specific route with the same URI is a different route.
+ if ($route->uri() === $uri && $route->getDomain() === null && in_array($descriptor->httpMethod, $route->methods(), true)) {
+ $patterns = [];
+ foreach ($route->wheres as $name => $pattern) {
+ if (is_string($name) && is_string($pattern)) {
+ $patterns[$name] = $pattern;
+ }
+ }
+
+ return ['uri' => $route->uri(), 'name' => $route->getName(), 'domain' => $route->getDomain(),
+ 'middleware' => array_values(array_filter($route->middleware(), is_string(...))), 'patterns' => $patterns];
+ }
+ }
+
+ return null;
+ }
+
+ /** @return list */
+ public function advice(RouteDescriptor $route): array
+ {
+ if (! $this->container->bound(ProxyPlan::class)) {
+ return [];
+ }
+ $plan = $this->container->make(ProxyPlan::class);
+ $kinds = $plan->adviceFor($route->controllerClass);
+ $rows = [];
+ foreach ($plan->methodsFor($route->controllerClass)[$route->methodName] ?? [] as $link) {
+ $kind = $kinds[$link['advice']] ?? null;
+ if ($kind === null) {
+ continue;
+ }
+ // Read raw rows across the existing Admin→Data edge; descriptor strings are data, never imports.
+ // An unbound interceptor is inert ONLY if that advice explicitly permits it; otherwise boot fails.
+ $inert = $kind->inertWhenUnbound;
+ $rows[] = ['id' => $link['advice'], 'order' => $kind->order, 'interceptor' => $kind->interceptorClass,
+ 'state' => $this->container->bound($kind->interceptorClass) ? 'LIVE' : ($inert ? 'INERT' : 'UNBOUND'),
+ 'contract' => json_encode($link['row'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE) ?: '{}'];
+ }
+
+ return $rows;
+ }
+
+ /** @return list */
+ private function failures(RouteBinding $binding): array
+ {
+ $plan = $binding->plan;
+ $kind = $plan['kind'];
+ $failures = [];
+ $add = static function (int $status, string $code, string $reason) use (&$failures, $plan): void {
+ $failures[] = ['status' => $status, 'code' => $code, 'binding' => $plan['name'], 'reason' => $reason];
+ };
+ if ($plan['required'] && in_array($kind, ['path', 'query', 'header', 'file'], true)) {
+ $add(400, 'MISSING_PARAMETER', 'Required '.$kind.' value is absent.');
+ }
+ if (($kind === 'file') || (in_array($kind, ['path', 'query', 'header'], true) && in_array($plan['type'], ['int', 'float', 'bool'], true))) {
+ $add(400, 'TYPE_CONVERSION_ERROR', $kind === 'file' ? 'Multiple uploads were sent to a single-file argument.' : 'The value cannot be converted to '.$plan['type'].'.');
+ }
+ if ($kind === 'path' && isset($plan['pattern'])) {
+ $add(404, $plan['notFoundCode'] ?? 'RESOURCE_NOT_FOUND', $binding->notFoundMessage ?? 'Path pattern did not match.');
+ }
+ if ($kind === 'file') {
+ $add(400, 'INVALID_UPLOAD', 'The upload did not complete.');
+ }
+ if ($kind === 'body') {
+ $add(400, 'MALFORMED_BODY', 'The body reader rejects malformed JSON.');
+ $add(400, 'INVALID_REQUEST', 'Unsupported Content-Type or a decoded value that is not an array.');
+ if ($plan['type'] !== null && class_exists($plan['type'])) {
+ $add(400, 'UNBINDABLE_BODY', 'The decoded body cannot construct the DTO.');
+ }
+ if ($plan['valid'] && $plan['type'] !== null) {
+ $add(422, 'VALIDATION_ERROR', 'Bean validation rejects the body before hydration.');
+ }
+ }
+
+ return $failures;
+ }
+}
diff --git a/packages/admin/src/RowComparator.php b/packages/admin/src/RowComparator.php
new file mode 100644
index 00000000..25f543be
--- /dev/null
+++ b/packages/admin/src/RowComparator.php
@@ -0,0 +1,152 @@
+', ?)` in SQL,
+ * where the empty string is simply the smallest string; ranking empties last there would make the same filter
+ * mean two things depending on whether the repository could page.
+ */
+final class RowComparator
+{
+ /**
+ * Where two values sit RELATIVE TO EACH OTHER on emptiness alone: 0 when both are empty or neither is,
+ * and otherwise the empty one last. Apply this before the direction, never through it — see the class
+ * docblock for why a descending sort must not mirror it.
+ */
+ public static function rankEmpty(mixed $a, mixed $b): int
+ {
+ return self::emptiness($a) <=> self::emptiness($b);
+ }
+
+ /**
+ * The ordering for ONE COLUMN: the choice between the two comparisons made ONCE, from everything the
+ * column holds, and then applied to every pair of it.
+ *
+ * THE CHOICE CANNOT BE MADE PER PAIR, and that is the whole reason this method exists rather than every
+ * caller reaching for `compare()`. Take a `version` column holding `1.10`, `1.9` and `1.9-beta` — a
+ * mixture no schema forbids and no validation catches. Pair by pair: `1.10 < 1.9`, because both are
+ * numeric and 1.1 is less than 1.9; `1.9 < 1.9-beta`, because one of them is not numeric and a prefix
+ * sorts before what extends it; and `1.9-beta < 1.10`, naturally, because 9 is less than 10. The relation
+ * closes a cycle, so it is not a strict weak ordering and `usort()` is entitled to answer anything: those
+ * three rows really do come back in three different orders depending on which order the payload arrived
+ * in, and the payload is rebuilt per request. That is exactly the "a row is then seen twice, and another
+ * never" that `Table\InMemoryListing`'s tiebreak exists to prevent — and the tiebreak cannot rescue it,
+ * because a tiebreak runs only where the primary comparison returned 0 and this one returns a confident,
+ * inconsistent non-zero.
+ *
+ * So the column commits before the first comparison: `compare()`'s arithmetic ONLY when every value in it
+ * is a number, and otherwise the natural text comparison for every pair. Empties are skipped by the scan
+ * rather than counted against it — they are ranked out by `rankEmpty()` before any of this is reached,
+ * and where a caller compares them anyway (a tiebreak does) they are the smallest text under either
+ * rule, so a nullable column of numbers keeps its arithmetic. `9` still precedes `100` in a column of
+ * durations, `Bean2` still precedes `Bean10` in a column of class names, and a column that mixes the two
+ * now answers the same way whatever order it was handed in.
+ *
+ * @param iterable $values every value the column holds across the rows about to be ordered
+ * @return Closure(mixed, mixed): int
+ */
+ public static function forColumn(iterable $values): Closure
+ {
+ foreach ($values as $value) {
+ if ($value === null || $value === '' || is_numeric($value)) {
+ continue;
+ }
+
+ return static fn (mixed $a, mixed $b): int => strnatcasecmp(self::text($a), self::text($b));
+ }
+
+ return self::compare(...);
+ }
+
+ /**
+ * Identity cannot equate distinct spellings merely because their friendly ordering ties.
+ *
+ * @param iterable $values
+ * @return Closure(mixed, mixed): int
+ */
+ public static function forIdentity(iterable $values): Closure
+ {
+ $compare = self::forColumn($values);
+
+ return static fn (mixed $a, mixed $b): int => $compare($a, $b) ?: strcmp(self::text($a), self::text($b));
+ }
+
+ /**
+ * Order two values as a reader would expect them ordered, emptiness aside.
+ *
+ * Numbers compare as numbers, so `9` precedes `100` and a column of durations is not sorted by its first
+ * digit. The addition rather than a float cast keeps a nineteen-digit identifier exact: past 2^53 two
+ * adjacent snowflake ids cast to the same float and would compare equal. Everything else compares
+ * naturally and case-insensitively, so `Bean2` precedes `Bean10` and a list of class names does not split
+ * into an upper-case half and a lower-case one.
+ *
+ * THIS IS THE PAIRWISE RULE, and on its own it belongs only where there is no column to be consistent
+ * with: the unpaged `gt`/`lt` filter, which holds one cell against one operand the reader typed and asks
+ * a yes-or-no question about the two of them. A SORT goes through `forColumn()`, which picks between the
+ * two comparisons above once for the whole column — see there for the cycle a per-pair choice opens.
+ */
+ public static function compare(mixed $a, mixed $b): int
+ {
+ if (is_numeric($a) && is_numeric($b)) {
+ return ($a + 0) <=> ($b + 0);
+ }
+
+ return strnatcasecmp(self::text($a), self::text($b));
+ }
+
+ /**
+ * The text of a cell, for comparing it and for searching inside it.
+ *
+ * An array is rendered as its JSON rather than dropped, because a listing of, say, a bean's dependencies
+ * is an array cell an operator does searches inside; an object falls back to its type, which is the only
+ * thing that can be said about it without calling code this class does not own.
+ */
+ public static function text(mixed $value): string
+ {
+ return match (true) {
+ $value === null => '',
+ is_bool($value) => $value ? 'true' : 'false',
+ is_scalar($value) => (string) $value,
+ is_array($value) => (string) json_encode($value, JSON_UNESCAPED_SLASHES),
+ default => get_debug_type($value),
+ };
+ }
+
+ private static function emptiness(mixed $value): int
+ {
+ return $value === null || $value === '' ? 1 : 0;
+ }
+}
diff --git a/packages/admin/src/Table/ColumnKind.php b/packages/admin/src/Table/ColumnKind.php
new file mode 100644
index 00000000..54b7e3d6
--- /dev/null
+++ b/packages/admin/src/Table/ColumnKind.php
@@ -0,0 +1,82 @@
+value;
+ }
+
+ /** Whether this kind is sized from its own alphabet rather than from what is left over. */
+ public function isRigid(): bool
+ {
+ return match ($this) {
+ self::Pill, self::Number, self::Stamp, self::Meter, self::Actions => true,
+ self::Token, self::Path, self::Qualified, self::Text, self::Line => false,
+ };
+ }
+
+ /** A picture of another column, and a set of controls, have nothing to order by. */
+ public function isOrderable(): bool
+ {
+ return $this !== self::Meter && $this !== self::Actions;
+ }
+}
diff --git a/packages/admin/src/Table/InMemoryListing.php b/packages/admin/src/Table/InMemoryListing.php
new file mode 100644
index 00000000..a7825668
--- /dev/null
+++ b/packages/admin/src/Table/InMemoryListing.php
@@ -0,0 +1,147 @@
+
+ *
+ * @param list $rows the whole listing, keyed by column key
+ * @param list $searchable the keys `?q=` looks inside — never every key, because searching a
+ * column the page does not show answers a question about a value the
+ * reader cannot see
+ * @param string $tiebreak a key whose value is unique per row
+ * @return ListingPage
+ */
+ public static function page(array $rows, ListingQuery $query, array $searchable, string $tiebreak): ListingPage
+ {
+ $ordered = self::order(
+ self::search($rows, $query->search, $searchable),
+ $query->sort ?? $tiebreak,
+ $query->direction,
+ $tiebreak,
+ );
+
+ $total = count($ordered);
+ $page = ListingPage::pageFor($total, $query);
+
+ return ListingPage::sliced(
+ array_slice($ordered, ($page - 1) * $query->size, $query->size),
+ $total,
+ $query,
+ );
+ }
+
+ /**
+ * @template TRow of array
+ *
+ * @param list $rows
+ * @param list $searchable
+ * @return list
+ */
+ private static function search(array $rows, ?string $term, array $searchable): array
+ {
+ if ($term === null || $searchable === []) {
+ return $rows;
+ }
+
+ $needle = mb_strtolower($term);
+
+ return array_values(array_filter($rows, static function (array $row) use ($needle, $searchable): bool {
+ foreach ($searchable as $key) {
+ if (str_contains(mb_strtolower(RowComparator::text($row[$key] ?? null)), $needle)) {
+ return true;
+ }
+ }
+
+ return false;
+ }));
+ }
+
+ /**
+ * @template TRow of array
+ *
+ * @param list $rows
+ * @return list
+ */
+ private static function order(array $rows, string $column, string $direction, string $tiebreak): array
+ {
+ // ONE COMPARISON PER COLUMN, chosen before the first pair is looked at. A column that mixes numbers
+ // with anything else has no consistent answer pair by pair — see RowComparator::forColumn() for the
+ // cycle — and usort() then returns whatever the arrival order suggested, which is the unstable page
+ // this class's tiebreak exists to prevent. The tiebreak is a column too, and is chosen the same way:
+ // an inconsistent tiebreak breaks the whole ordering just as thoroughly as an inconsistent primary.
+ $values = self::valuesOf($rows, $column);
+ $compare = $column === $tiebreak
+ ? RowComparator::forIdentity($values)
+ : RowComparator::forColumn($values);
+ $breakTie = RowComparator::forIdentity(self::valuesOf($rows, $tiebreak));
+
+ usort($rows, static function (array $a, array $b) use ($column, $direction, $tiebreak, $compare, $breakTie): int {
+ $left = $a[$column] ?? null;
+ $right = $b[$column] ?? null;
+
+ // Emptiness OUTSIDE the direction: an em-dash is the absence of a value, not a value that sorts
+ // low, so it stays at the end of the listing whichever way the column was asked to run.
+ $comparison = RowComparator::rankEmpty($left, $right);
+
+ if ($comparison === 0) {
+ $comparison = $compare($left, $right);
+
+ if ($direction === 'desc') {
+ $comparison = -$comparison;
+ }
+ }
+
+ return $comparison !== 0
+ ? $comparison
+ : $breakTie($a[$tiebreak] ?? null, $b[$tiebreak] ?? null);
+ });
+
+ return $rows;
+ }
+
+ /**
+ * One column of the listing, missing cells included as the `null` the ordering will see — `array_column()`
+ * DROPS a row that lacks the key, and a column is judged numeric or not by what the comparison will
+ * actually be handed.
+ *
+ * @param list> $rows
+ * @return list
+ */
+ private static function valuesOf(array $rows, string $key): array
+ {
+ return array_map(static fn (array $row): mixed => $row[$key] ?? null, $rows);
+ }
+}
diff --git a/packages/admin/src/Table/ListingPage.php b/packages/admin/src/Table/ListingPage.php
new file mode 100644
index 00000000..5066df86
--- /dev/null
+++ b/packages/admin/src/Table/ListingPage.php
@@ -0,0 +1,115 @@
+` is. The rows are `list` as far as this object is
+ * concerned — it never looks inside one — but a caller that hands it a precisely-shaped `list`
+ * gets that shape back out of `$rows`, which is what keeps a view (and PHPStan at level max) from having to
+ * re-assert the shape of every cell it draws.
+ *
+ * @template TRow
+ */
+final readonly class ListingPage
+{
+ /**
+ * @param list $rows
+ */
+ private function __construct(
+ public array $rows,
+ public int $total,
+ public int $page,
+ public ListingQuery $query,
+ ) {}
+
+ /**
+ * @template TSliced
+ *
+ * @param list $rows the page's rows — already sliced, by whatever did the slicing
+ * @return self
+ */
+ public static function sliced(array $rows, int $total, ListingQuery $query): self
+ {
+ $total = max(0, $total);
+
+ return new self($rows, $total, self::pageFor($total, $query), $query);
+ }
+
+ /** The requested page clamped into `[1, last]` — the page a caller must actually slice for. */
+ public static function pageFor(int $total, ListingQuery $query): int
+ {
+ return min(max(1, $query->page), self::lastPageFor($total, $query->size));
+ }
+
+ /** Always at least 1: an empty listing has one (empty) page, not zero. */
+ public static function lastPageFor(int $total, int $size): int
+ {
+ return $size > 0 ? max(1, (int) ceil($total / $size)) : 1;
+ }
+
+ public function lastPage(): int
+ {
+ return self::lastPageFor($this->total, $this->query->size);
+ }
+
+ public function from(): int
+ {
+ return $this->total === 0 ? 0 : ($this->page - 1) * $this->query->size + 1;
+ }
+
+ public function to(): int
+ {
+ return min($this->total, $this->page * $this->query->size);
+ }
+
+ public function hasPrevious(): bool
+ {
+ return $this->page > 1;
+ }
+
+ public function hasNext(): bool
+ {
+ return $this->page < $this->lastPage();
+ }
+
+ public function isEmpty(): bool
+ {
+ return $this->rows === [];
+ }
+
+ /** Whether the pager's page controls are worth rendering at all. */
+ public function isPaged(): bool
+ {
+ return $this->lastPage() > 1;
+ }
+
+ /**
+ * A window around the current page. Rendering every page of a four-hundred-page listing is a control
+ * nobody can use; the ends are kept by the pager itself, because "first" and "last" are the two jumps
+ * people actually make.
+ *
+ * @return list
+ */
+ public function window(int $radius = 2): array
+ {
+ return range(max(1, $this->page - $radius), min($this->lastPage(), $this->page + $radius));
+ }
+
+ public function link(int $page): string
+ {
+ return $this->query->link(['page' => $page]);
+ }
+}
diff --git a/packages/admin/src/Table/ListingQuery.php b/packages/admin/src/Table/ListingQuery.php
new file mode 100644
index 00000000..65d4eb17
--- /dev/null
+++ b/packages/admin/src/Table/ListingQuery.php
@@ -0,0 +1,296 @@
+ $sortable the column keys this listing will accept in `?sort=`
+ * @param array> $carried parameters this listing does not own and must not lose
+ */
+ public function __construct(
+ public TableSettings $settings,
+ public string $path,
+ public int $page,
+ public int $size,
+ public ?string $sort,
+ public string $direction,
+ public ?string $search,
+ public array $sortable = [],
+ public array $carried = [],
+ public string $qualifier = '',
+ public ?string $defaultSort = null,
+ public string $defaultDirection = 'asc',
+ ) {}
+
+ /**
+ * @param list $sortable
+ * @param array> $carried
+ */
+ public static function fromRequest(
+ Request $request,
+ TableSettings $settings,
+ string $path,
+ array $sortable = [],
+ ?string $defaultSort = null,
+ string $defaultDirection = 'asc',
+ array $carried = [],
+ string $qualifier = '',
+ ): self {
+ $read = static function (string $parameter) use ($request, $qualifier): ?string {
+ $value = $request->query($qualifier === '' ? $parameter : $qualifier.'_'.$parameter);
+
+ return is_string($value) ? $value : null;
+ };
+
+ $page = $read('page');
+ $size = $read('size');
+ $requested = $read('sort');
+ $search = trim($read('q') ?? '');
+
+ $sort = $requested !== null && in_array($requested, $sortable, true) ? $requested : $defaultSort;
+
+ return new self(
+ settings: $settings,
+ path: $path,
+ page: $page !== null && ctype_digit($page) ? max(1, (int) $page) : 1,
+ size: $settings->clamp($size !== null && ctype_digit($size) ? (int) $size : null),
+ sort: $sort,
+ direction: $sort === null ? $defaultDirection : match ($read('dir')) {
+ 'desc' => 'desc',
+ 'asc' => 'asc',
+ default => $defaultDirection,
+ },
+ search: $search === '' ? null : mb_substr($search, 0, self::MAX_SEARCH_LENGTH),
+ sortable: $sortable,
+ carried: $carried,
+ qualifier: $qualifier,
+ defaultSort: $defaultSort,
+ defaultDirection: $defaultDirection,
+ );
+ }
+
+ /**
+ * This listing's URL with some of its state replaced. An override is keyed by the UNQUALIFIED parameter
+ * name and `null` removes it, so `link(['page' => null])` is "the same view, from the top".
+ *
+ * @param array $overrides
+ */
+ public function link(array $overrides = []): string
+ {
+ /** @var array $state */
+ $state = [
+ 'q' => $this->search,
+ 'sort' => $this->sort,
+ 'dir' => $this->direction,
+ 'size' => $this->size,
+ 'page' => $this->page,
+ ...$overrides,
+ ];
+
+ $parameters = $this->carried;
+ foreach ($this->meaningful($state) as $parameter => $value) {
+ $parameters[$this->name($parameter)] = $value;
+ }
+
+ $query = http_build_query($parameters);
+
+ return $query === '' ? $this->path : $this->path.'?'.$query;
+ }
+
+ /** The link the column header points at: this column's ordering, from the first page. */
+ public function sortLink(string $column): string
+ {
+ return $this->link(['sort' => $column, 'dir' => $this->nextDirection($column), 'page' => null]);
+ }
+
+ public function nextDirection(string $column): string
+ {
+ return $this->isSortedBy($column) && $this->direction === 'asc' ? 'desc' : 'asc';
+ }
+
+ public function isSortedBy(string $column): bool
+ {
+ return $this->sort !== null && $this->sort === $column;
+ }
+
+ /** The arrow a sorted header carries — empty for every other column, so the page has exactly one. */
+ public function indicator(string $column): string
+ {
+ return $this->isSortedBy($column) ? ($this->direction === 'asc' ? '↑' : '↓') : '';
+ }
+
+ public function isFiltered(): bool
+ {
+ return $this->search !== null;
+ }
+
+ /**
+ * The state a GET search form must re-submit, as hidden inputs.
+ *
+ * `q` is absent because the form's own input supplies it, and `page` is absent because a new search
+ * starts at the top — page 4 of a result set that no longer exists is an empty table with a pager.
+ *
+ * @return list
+ */
+ public function hiddenFields(): array
+ {
+ $fields = [];
+
+ foreach ($this->carried as $parameter => $value) {
+ foreach (is_array($value) ? $value : [$value] as $one) {
+ $fields[] = ['name' => is_array($value) ? $parameter.'[]' : $parameter, 'value' => $one];
+ }
+ }
+
+ foreach ($this->meaningful(['sort' => $this->sort, 'dir' => $this->direction, 'size' => $this->size]) as $parameter => $value) {
+ $fields[] = ['name' => $this->name($parameter), 'value' => $value];
+ }
+
+ return $fields;
+ }
+
+ /**
+ * This listing's own parameters, qualified — what the OTHER listing on the same page has to carry.
+ *
+ * @return array
+ */
+ public function own(): array
+ {
+ $own = [];
+ foreach ($this->meaningful(['q' => $this->search, 'sort' => $this->sort, 'dir' => $this->direction, 'size' => $this->size, 'page' => $this->page]) as $parameter => $value) {
+ $own[$this->name($parameter)] = $value;
+ }
+
+ return $own;
+ }
+
+ /** @param array> $parameters */
+ public function carrying(array $parameters): self
+ {
+ return new self(
+ settings: $this->settings,
+ path: $this->path,
+ page: $this->page,
+ size: $this->size,
+ sort: $this->sort,
+ direction: $this->direction,
+ search: $this->search,
+ sortable: $this->sortable,
+ carried: [...$this->carried, ...$parameters],
+ qualifier: $this->qualifier,
+ defaultSort: $this->defaultSort,
+ defaultDirection: $this->defaultDirection,
+ );
+ }
+
+ /**
+ * The same listing at the size it was actually served.
+ *
+ * THIS EXISTS BECAUSE ONE SIDE ASKS AND THE OTHER SERVES. Every actuator listing is sliced by
+ * `InMemoryListing` at exactly `$size`, so for those this is never called. The data browser is the
+ * caller: `DataBrowser::list()` applies `firefly.admin.data.max-page-size` to whatever it is handed, and
+ * a page that merely ASSUMED the two agree would be assuming something it cannot see from the outside.
+ * `TableSettings::boundedBy()` composes that cap into the settings this query was parsed against
+ * precisely so they do agree, and this is the line that makes the agreement a fact rather than a hope.
+ * Were they ever to part, every number a pager draws — the range readout, the last page, whether `Next`
+ * is live — would still be computed from the size that was refused: over a served 50 a query stating 100
+ * claims half as many pages as there are and disables `Next` with the second half of the table
+ * unreached. So the query is re-stated at the size that was served, and every link it then writes
+ * carries that size rather than the one it asked for. A re-stated size is the one size on this object
+ * that the offered set did not choose, which is why the rows-per-page control renders
+ * `TableSettings::offering()` rather than the set itself.
+ */
+ public function sized(int $size): self
+ {
+ return $size === $this->size ? $this : new self(
+ settings: $this->settings,
+ path: $this->path,
+ page: $this->page,
+ size: $size,
+ sort: $this->sort,
+ direction: $this->direction,
+ search: $this->search,
+ sortable: $this->sortable,
+ carried: $this->carried,
+ qualifier: $this->qualifier,
+ defaultSort: $this->defaultSort,
+ defaultDirection: $this->defaultDirection,
+ );
+ }
+
+ /**
+ * The parameters worth writing down: everything that is set and is not already the default.
+ *
+ * `dir` is the subtle one. With no sort at all the direction says nothing, so its "default" is taken to
+ * be whatever it currently is and it drops out; with a sort, it is written only when it differs from
+ * the listing's declared default — which is how `/firefly/http` stays the URL of the newest-first page
+ * it opens on.
+ *
+ * @param array $state
+ * @return array
+ */
+ private function meaningful(array $state): array
+ {
+ $sort = $state['sort'] ?? null;
+
+ $meaningful = [];
+ foreach ($state as $parameter => $value) {
+ $default = match ($parameter) {
+ 'sort' => $this->defaultSort,
+ 'dir' => $sort === null || $sort === '' ? $value : $this->defaultDirection,
+ 'size' => $this->settings->pageSize,
+ 'page' => 1,
+ default => null,
+ };
+
+ if ($value !== null && $value !== '' && $value !== $default) {
+ $meaningful[$parameter] = (string) $value;
+ }
+ }
+
+ return $meaningful;
+ }
+
+ private function name(string $parameter): string
+ {
+ return $this->qualifier === '' ? $parameter : $this->qualifier.'_'.$parameter;
+ }
+}
diff --git a/packages/admin/src/Table/TableColumn.php b/packages/admin/src/Table/TableColumn.php
new file mode 100644
index 00000000..21ffe1d2
--- /dev/null
+++ b/packages/admin/src/Table/TableColumn.php
@@ -0,0 +1,171 @@
+`'s `ch` is the 12.5px monospace advance the sheet declares on `table.ftable colgroup`
+ * (7.52px in Chromium), while `thead th` is 10px/700 uppercase in the UI face with `.12em` tracking.
+ * Measured across the labels a schema actually produces — `Id` 0.88, `Unit price` 0.95,
+ * `Failed login attempts` 0.99, `Created at` 1.02, `Warehouse manager` 1.12, `Customer` 1.14,
+ * `Amount` 1.20 — one header character costs between 0.88 and 1.20 of those `ch`, the top of the range
+ * being the short, round-lettered words rather than the long labels. Linux's system face puts
+ * `Amount` at 1.26, so 1.30 leaves room across both font stacks instead of clipping it at 1.25.
+ *
+ * IT IS AN ESTIMATE, AND THE HEADER THAT USES IT SAYS SO. PHP cannot measure a font, so a pathological
+ * label — twelve `W`s measures 1.52 — still overflows its column; the data browser therefore puts the
+ * full label on the `
`'s `title`, exactly as `_cell` already does for a value it clips.
+ */
+ public const float HEADER_CH_PER_CHARACTER = 1.30;
+
+ /**
+ * What the ordering indicator adds to a SORTABLE header, in the same `ch`.
+ *
+ * `th .ord` is a 10px-wide inline-block inside an `inline-flex` anchor with `gap:3px`, and that width
+ * is declared rather than taken from the arrow — so the 13px is paid whether the column is the sorted
+ * one or not, which is what stops a header changing width under the click that sorts it. 13px over a
+ * 7.52px `ch` is 1.73, rounded up.
+ */
+ public const float SORT_INDICATOR_CH = 1.75;
+
+ private function __construct(
+ public string $key,
+ public string $label,
+ public ColumnKind $kind,
+ public float $width,
+ public bool $sortable,
+ public string $separator,
+ ) {}
+
+ /** A chip. 7.5 characters is `DELETE` with room — the width the Routes page was clipping. */
+ public static function pill(string $key, string $label, float $ch = 7.5, bool $sortable = true): self
+ {
+ return new self($key, $label, ColumnKind::Pill, $ch, $sortable, '');
+ }
+
+ public static function number(string $key, string $label, float $ch = 9, bool $sortable = true): self
+ {
+ return new self($key, $label, ColumnKind::Number, $ch, $sortable, '');
+ }
+
+ /** 19 characters is `2026-09-22 20:49:26`; an age reads shorter and is padded by the same rule. */
+ public static function stamp(string $key, string $label, float $ch = 19, bool $sortable = true): self
+ {
+ return new self($key, $label, ColumnKind::Stamp, $ch, $sortable, '');
+ }
+
+ public static function meter(string $label, float $ch = 16): self
+ {
+ return new self('', $label, ColumnKind::Meter, $ch, false, '');
+ }
+
+ public static function actions(string $label = '', float $ch = 11): self
+ {
+ return new self('', $label, ColumnKind::Actions, $ch, false, '');
+ }
+
+ public static function token(string $key, string $label, float $weight = 2, bool $sortable = true): self
+ {
+ return new self($key, $label, ColumnKind::Token, $weight, $sortable, '');
+ }
+
+ public static function path(string $key, string $label, float $weight = 5, bool $sortable = true): self
+ {
+ return new self($key, $label, ColumnKind::Path, $weight, $sortable, '');
+ }
+
+ /**
+ * @param string $separator what the name is qualified by — `\` for a class, `.` for a config key or a
+ * meter name, and `''` when the two lines come from two different fields
+ * rather than from splitting one (the OAuth2 page's client id over client
+ * name), in which case the view supplies both halves itself
+ */
+ public static function qualified(string $key, string $label, float $weight = 4, string $separator = '\\', bool $sortable = true): self
+ {
+ return new self($key, $label, ColumnKind::Qualified, $weight, $sortable, $separator);
+ }
+
+ public static function text(string $key, string $label, float $weight = 4, bool $sortable = false): self
+ {
+ return new self($key, $label, ColumnKind::Text, $weight, $sortable, '');
+ }
+
+ public static function line(string $key, string $label, float $weight = 4, bool $sortable = true): self
+ {
+ return new self($key, $label, ColumnKind::Line, $weight, $sortable, '');
+ }
+
+ /**
+ * The same column, widened until its own HEADER fits beside its values.
+ *
+ * A rigid width is sized from the alphabet the CELLS can hold — nineteen characters for an ISO instant,
+ * seven and a half for the verb `DELETE` — and on a listing with hand-written labels that is the whole
+ * story, because the author picked the label against the width. It stops being the whole story the
+ * moment the label is derived. `thead th` is `white-space:nowrap` and `table.ftable td,th` is
+ * `overflow:hidden`, so a header wider than its column is cut mid-glyph; and under the
+ * `table-layout:fixed` this system runs on, the column cannot grow to rescue it the way the
+ * `table-layout:auto` the data browser used to run on did. So the width becomes the greater of the two
+ * needs, never the lesser — a column still holds its values, and now it also says what they are.
+ *
+ * A FLEXIBLE COLUMN IS RETURNED UNTOUCHED, because its `$width` is a weight and not a character count.
+ * Widening it would not widen a column; it would enlarge that column's share of the leftovers at its
+ * neighbours' expense, for a reason that has nothing to do with them — and a percentage column on a
+ * table this wide clears any header it is likely to be given anyway.
+ */
+ public function fittingItsHeader(): self
+ {
+ if (! $this->isRigid()) {
+ return $this;
+ }
+
+ $header = mb_strlen($this->label) * self::HEADER_CH_PER_CHARACTER
+ + ($this->isSortable() ? self::SORT_INDICATOR_CH : 0.0);
+
+ return $header <= $this->width
+ ? $this
+ : new self($this->key, $this->label, $this->kind, $header, $this->sortable, $this->separator);
+ }
+
+ public function cssClass(): string
+ {
+ return $this->kind->cssClass();
+ }
+
+ public function isRigid(): bool
+ {
+ return $this->kind->isRigid();
+ }
+
+ /** Sortable needs a key to sort BY and a kind that can be ordered — both, not either. */
+ public function isSortable(): bool
+ {
+ return $this->sortable && $this->key !== '' && $this->kind->isOrderable();
+ }
+}
diff --git a/packages/admin/src/Table/TableSettings.php b/packages/admin/src/Table/TableSettings.php
new file mode 100644
index 00000000..5b18942a
--- /dev/null
+++ b/packages/admin/src/Table/TableSettings.php
@@ -0,0 +1,219 @@
+` renders, so the
+ * configured default is ALWAYS a member of it: a `