@fougere/admin 0.9.2-alpha.0 → 0.10.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/dist/{extensions.d.ts → AdminExtension.d.ts} +5 -14
  2. package/dist/AdminExtension.d.ts.map +1 -0
  3. package/dist/{extensions.js → AdminExtension.js} +3 -3
  4. package/dist/AdminExtension.js.map +1 -0
  5. package/dist/AdminFacetRegistry.d.ts +9 -0
  6. package/dist/AdminFacetRegistry.d.ts.map +1 -0
  7. package/dist/AdminFacetRegistry.js +2 -0
  8. package/dist/AdminFacetRegistry.js.map +1 -0
  9. package/dist/AdminFacets.d.ts +6 -0
  10. package/dist/AdminFacets.d.ts.map +1 -0
  11. package/dist/{facets.js → AdminFacets.js} +1 -1
  12. package/dist/AdminFacets.js.map +1 -0
  13. package/dist/AdminFieldExtension.d.ts +7 -0
  14. package/dist/AdminFieldExtension.d.ts.map +1 -0
  15. package/dist/AdminFieldExtension.js +2 -0
  16. package/dist/AdminFieldExtension.js.map +1 -0
  17. package/dist/AdminOperation.d.ts +9 -0
  18. package/dist/AdminOperation.d.ts.map +1 -0
  19. package/dist/AdminOperation.js +2 -0
  20. package/dist/AdminOperation.js.map +1 -0
  21. package/dist/AdminOperationExtension.d.ts +6 -0
  22. package/dist/AdminOperationExtension.d.ts.map +1 -0
  23. package/dist/AdminOperationExtension.js +2 -0
  24. package/dist/AdminOperationExtension.js.map +1 -0
  25. package/dist/{resources.d.ts → AdminResource.d.ts} +11 -17
  26. package/dist/AdminResource.d.ts.map +1 -0
  27. package/dist/{resources.js → AdminResource.js} +13 -14
  28. package/dist/AdminResource.js.map +1 -0
  29. package/dist/AdminRuntime.d.ts +11 -0
  30. package/dist/AdminRuntime.d.ts.map +1 -0
  31. package/dist/{runtime.js → AdminRuntime.js} +5 -6
  32. package/dist/AdminRuntime.js.map +1 -0
  33. package/dist/AdminRuntimeOptions.d.ts +8 -0
  34. package/dist/AdminRuntimeOptions.d.ts.map +1 -0
  35. package/dist/AdminRuntimeOptions.js +2 -0
  36. package/dist/AdminRuntimeOptions.js.map +1 -0
  37. package/dist/Drawing.d.ts +39 -0
  38. package/dist/Drawing.d.ts.map +1 -0
  39. package/dist/Drawing.js +212 -0
  40. package/dist/Drawing.js.map +1 -0
  41. package/dist/EditorialFacet.d.ts +13 -0
  42. package/dist/EditorialFacet.d.ts.map +1 -0
  43. package/dist/EditorialFacet.js +2 -0
  44. package/dist/EditorialFacet.js.map +1 -0
  45. package/dist/FougereAdminProps.d.ts +17 -0
  46. package/dist/FougereAdminProps.d.ts.map +1 -0
  47. package/dist/FougereAdminProps.js +2 -0
  48. package/dist/FougereAdminProps.js.map +1 -0
  49. package/dist/FougereDashboardContextValue.d.ts +11 -0
  50. package/dist/FougereDashboardContextValue.d.ts.map +1 -0
  51. package/dist/FougereDashboardContextValue.js +2 -0
  52. package/dist/FougereDashboardContextValue.js.map +1 -0
  53. package/dist/FougereDashboardExtension.d.ts +13 -0
  54. package/dist/FougereDashboardExtension.d.ts.map +1 -0
  55. package/dist/FougereDashboardExtension.js +2 -0
  56. package/dist/FougereDashboardExtension.js.map +1 -0
  57. package/dist/FougereDashboardMetrics.d.ts +7 -0
  58. package/dist/FougereDashboardMetrics.d.ts.map +1 -0
  59. package/dist/FougereDashboardMetrics.js +2 -0
  60. package/dist/FougereDashboardMetrics.js.map +1 -0
  61. package/dist/FougereDashboardResource.d.ts +14 -0
  62. package/dist/FougereDashboardResource.d.ts.map +1 -0
  63. package/dist/FougereDashboardResource.js +2 -0
  64. package/dist/FougereDashboardResource.js.map +1 -0
  65. package/dist/FougereDashboardWidget.d.ts +11 -0
  66. package/dist/FougereDashboardWidget.d.ts.map +1 -0
  67. package/dist/FougereDashboardWidget.js +2 -0
  68. package/dist/FougereDashboardWidget.js.map +1 -0
  69. package/dist/FougereDashboardZone.d.ts +2 -0
  70. package/dist/FougereDashboardZone.d.ts.map +1 -0
  71. package/dist/FougereDashboardZone.js +2 -0
  72. package/dist/FougereDashboardZone.js.map +1 -0
  73. package/dist/{provider.d.ts → FougereDataProvider.d.ts} +2 -17
  74. package/dist/FougereDataProvider.d.ts.map +1 -0
  75. package/dist/{provider.js → FougereDataProvider.js} +2 -3
  76. package/dist/FougereDataProvider.js.map +1 -0
  77. package/dist/FougereResourceOptions.d.ts +16 -0
  78. package/dist/FougereResourceOptions.d.ts.map +1 -0
  79. package/dist/FougereResourceOptions.js +2 -0
  80. package/dist/FougereResourceOptions.js.map +1 -0
  81. package/dist/LoadedAdmin.d.ts +7 -0
  82. package/dist/LoadedAdmin.d.ts.map +1 -0
  83. package/dist/LoadedAdmin.js +2 -0
  84. package/dist/LoadedAdmin.js.map +1 -0
  85. package/dist/PlacedEdge.d.ts +17 -0
  86. package/dist/PlacedEdge.d.ts.map +1 -0
  87. package/dist/PlacedEdge.js +2 -0
  88. package/dist/PlacedEdge.js.map +1 -0
  89. package/dist/PlacedNode.d.ts +10 -0
  90. package/dist/PlacedNode.d.ts.map +1 -0
  91. package/dist/PlacedNode.js +2 -0
  92. package/dist/PlacedNode.js.map +1 -0
  93. package/dist/Point.d.ts +5 -0
  94. package/dist/Point.d.ts.map +1 -0
  95. package/dist/Point.js +2 -0
  96. package/dist/Point.js.map +1 -0
  97. package/dist/ProviderOptions.d.ts +10 -0
  98. package/dist/ProviderOptions.d.ts.map +1 -0
  99. package/dist/ProviderOptions.js +2 -0
  100. package/dist/ProviderOptions.js.map +1 -0
  101. package/dist/ReactAdminFieldContext.d.ts +10 -0
  102. package/dist/ReactAdminFieldContext.d.ts.map +1 -0
  103. package/dist/ReactAdminFieldContext.js +2 -0
  104. package/dist/ReactAdminFieldContext.js.map +1 -0
  105. package/dist/ReactAdminFieldRenderer.d.ts +4 -0
  106. package/dist/ReactAdminFieldRenderer.d.ts.map +1 -0
  107. package/dist/ReactAdminFieldRenderer.js +2 -0
  108. package/dist/ReactAdminFieldRenderer.js.map +1 -0
  109. package/dist/ReactAdminInputContext.d.ts +9 -0
  110. package/dist/ReactAdminInputContext.d.ts.map +1 -0
  111. package/dist/ReactAdminInputContext.js +2 -0
  112. package/dist/ReactAdminInputContext.js.map +1 -0
  113. package/dist/ReactAdminInputRenderer.d.ts +4 -0
  114. package/dist/ReactAdminInputRenderer.d.ts.map +1 -0
  115. package/dist/ReactAdminInputRenderer.js +2 -0
  116. package/dist/ReactAdminInputRenderer.js.map +1 -0
  117. package/dist/ReactAdminRenderers.d.ts +8 -0
  118. package/dist/ReactAdminRenderers.d.ts.map +1 -0
  119. package/dist/ReactAdminRenderers.js +2 -0
  120. package/dist/ReactAdminRenderers.js.map +1 -0
  121. package/dist/ReactAdminResourceComponents.d.ts +9 -0
  122. package/dist/ReactAdminResourceComponents.d.ts.map +1 -0
  123. package/dist/ReactAdminResourceComponents.js +2 -0
  124. package/dist/ReactAdminResourceComponents.js.map +1 -0
  125. package/dist/ResourceKey.d.ts +8 -0
  126. package/dist/ResourceKey.d.ts.map +1 -0
  127. package/dist/ResourceKey.js +2 -0
  128. package/dist/ResourceKey.js.map +1 -0
  129. package/dist/ResourceRenderOptions.d.ts +7 -0
  130. package/dist/ResourceRenderOptions.d.ts.map +1 -0
  131. package/dist/ResourceRenderOptions.js +2 -0
  132. package/dist/ResourceRenderOptions.js.map +1 -0
  133. package/dist/TopologyNode.d.ts +18 -0
  134. package/dist/TopologyNode.d.ts.map +1 -0
  135. package/dist/TopologyNode.js +2 -0
  136. package/dist/TopologyNode.js.map +1 -0
  137. package/dist/UsersFacet.d.ts +13 -0
  138. package/dist/UsersFacet.d.ts.map +1 -0
  139. package/dist/UsersFacet.js +2 -0
  140. package/dist/UsersFacet.js.map +1 -0
  141. package/dist/dashboard.d.ts +4 -61
  142. package/dist/dashboard.d.ts.map +1 -1
  143. package/dist/dashboard.js +9 -9
  144. package/dist/dashboard.js.map +1 -1
  145. package/dist/index.d.ts +3 -2
  146. package/dist/index.d.ts.map +1 -1
  147. package/dist/index.js +1 -1
  148. package/dist/index.js.map +1 -1
  149. package/dist/react.d.ts +21 -65
  150. package/dist/react.d.ts.map +1 -1
  151. package/dist/react.js +7 -7
  152. package/dist/react.js.map +1 -1
  153. package/dist/theme.js +1 -1
  154. package/dist/topology-page.d.ts.map +1 -1
  155. package/dist/topology-page.js +105 -11
  156. package/dist/topology-page.js.map +1 -1
  157. package/package.json +7 -5
  158. package/src/{extensions.ts → AdminExtension.ts} +5 -16
  159. package/src/AdminFacetRegistry.ts +9 -0
  160. package/src/AdminFacets.ts +22 -0
  161. package/src/AdminFieldExtension.ts +6 -0
  162. package/src/AdminOperation.ts +9 -0
  163. package/src/AdminOperationExtension.ts +5 -0
  164. package/src/{resources.ts → AdminResource.ts} +18 -26
  165. package/src/{runtime.ts → AdminRuntime.ts} +6 -24
  166. package/src/AdminRuntimeOptions.ts +8 -0
  167. package/src/Drawing.ts +261 -0
  168. package/src/EditorialFacet.ts +12 -0
  169. package/src/FougereAdminProps.ts +17 -0
  170. package/src/FougereDashboardContextValue.ts +11 -0
  171. package/src/FougereDashboardExtension.ts +13 -0
  172. package/src/FougereDashboardMetrics.ts +6 -0
  173. package/src/FougereDashboardResource.ts +14 -0
  174. package/src/FougereDashboardWidget.ts +11 -0
  175. package/src/FougereDashboardZone.ts +1 -0
  176. package/src/{provider.ts → FougereDataProvider.ts} +3 -26
  177. package/src/FougereResourceOptions.ts +13 -0
  178. package/src/LoadedAdmin.ts +7 -0
  179. package/src/PlacedEdge.ts +17 -0
  180. package/src/PlacedNode.ts +10 -0
  181. package/src/Point.ts +4 -0
  182. package/src/ProviderOptions.ts +10 -0
  183. package/src/ReactAdminFieldContext.ts +10 -0
  184. package/src/ReactAdminFieldRenderer.ts +4 -0
  185. package/src/ReactAdminInputContext.ts +9 -0
  186. package/src/ReactAdminInputRenderer.ts +4 -0
  187. package/src/ReactAdminRenderers.ts +8 -0
  188. package/src/ReactAdminResourceComponents.ts +9 -0
  189. package/src/ResourceKey.ts +7 -0
  190. package/src/ResourceRenderOptions.ts +7 -0
  191. package/src/TopologyNode.ts +18 -0
  192. package/src/UsersFacet.ts +12 -0
  193. package/src/dashboard.tsx +19 -88
  194. package/src/index.ts +3 -9
  195. package/src/react.tsx +37 -80
  196. package/src/theme.tsx +1 -1
  197. package/src/topology-page.tsx +202 -16
  198. package/template/index.html +5 -0
  199. package/template/package.json +22 -0
  200. package/template/src/main.tsx +27 -0
  201. package/template/tsconfig.json +12 -0
  202. package/template/vite.config.ts +16 -0
  203. package/dist/extensions.d.ts.map +0 -1
  204. package/dist/extensions.js.map +0 -1
  205. package/dist/facets.d.ts +0 -36
  206. package/dist/facets.d.ts.map +0 -1
  207. package/dist/facets.js.map +0 -1
  208. package/dist/provider.d.ts.map +0 -1
  209. package/dist/provider.js.map +0 -1
  210. package/dist/resources.d.ts.map +0 -1
  211. package/dist/resources.js.map +0 -1
  212. package/dist/runtime.d.ts +0 -22
  213. package/dist/runtime.d.ts.map +0 -1
  214. package/dist/runtime.js.map +0 -1
  215. package/dist/topology.d.ts +0 -18
  216. package/dist/topology.d.ts.map +0 -1
  217. package/dist/topology.js +0 -30
  218. package/dist/topology.js.map +0 -1
  219. package/src/facets.ts +0 -55
  220. package/src/topology.ts +0 -59
package/src/dashboard.tsx CHANGED
@@ -1,3 +1,10 @@
1
+ import type { FougereResourceOptions } from './FougereResourceOptions.js';
2
+ import type { FougereDashboardResource } from './FougereDashboardResource.js';
3
+ import type { FougereDashboardMetrics } from './FougereDashboardMetrics.js';
4
+ import type { FougereDashboardContextValue } from './FougereDashboardContextValue.js';
5
+ import type { FougereDashboardZone } from './FougereDashboardZone.js';
6
+ import type { FougereDashboardWidget } from './FougereDashboardWidget.js';
7
+ import type { FougereDashboardExtension } from './FougereDashboardExtension.js';
1
8
  'use client';
2
9
 
3
10
  import {
@@ -12,65 +19,11 @@ import {
12
19
  Typography,
13
20
  type SvgIconProps,
14
21
  } from '@mui/material';
15
- import {
16
- createContext,
17
- useContext,
18
- useEffect,
19
- useMemo,
20
- useState,
21
- type ComponentType,
22
- type ReactElement,
23
- } from 'react';
24
- import {
25
- useDataProvider,
26
- useLocaleState,
27
- useRedirect,
28
- useResourceDefinitions,
29
- useTranslate,
30
- Title,
31
- type ResourceOptions,
32
- } from 'react-admin';
33
- import type { AdminFacets, EditorialFacet, UsersFacet } from './facets.js';
34
-
35
- export interface FougereResourceOptions extends ResourceOptions {
36
- primary: string;
37
- facets: AdminFacets;
38
- /** The frond that owns this door — the card groups by it, so the panel can too. */
39
- frond?: string;
40
- /** What the door answers, with each op's kind. `query` reads, `command` writes. */
41
- operations?: readonly { name: string; kind: 'query' | 'command' }[];
42
- /** How many columns the shape yields — a rough measure of an entity's width. */
43
- fieldCount?: number;
44
- }
45
-
46
- export interface FougereDashboardResource {
47
- name: string;
48
- label: string;
49
- primary: string;
50
- facets: AdminFacets;
51
- hasCreate: boolean;
52
- hasEdit: boolean;
53
- hasShow: boolean;
54
- total: number;
55
- rows: Record<string, unknown>[];
56
- states: Record<string, number>;
57
- }
58
-
59
- export interface FougereDashboardMetrics {
60
- content: number;
61
- drafts: number;
62
- published: number;
63
- users: number;
64
- }
22
+ import { createContext, useContext, useEffect, useMemo, useState, type ReactElement } from 'react';
23
+ import { useDataProvider, useLocaleState, useRedirect, useResourceDefinitions, useTranslate, Title } from 'react-admin';
65
24
 
66
- export interface FougereDashboardContextValue {
67
- loading: boolean;
68
- resources: FougereDashboardResource[];
69
- editorial: FougereDashboardResource[];
70
- users: FougereDashboardResource[];
71
- metrics: FougereDashboardMetrics;
72
- navigate(view: 'list' | 'show' | 'edit' | 'create', resource: string, id?: string | number): void;
73
- }
25
+ import type { EditorialFacet } from './EditorialFacet.js';
26
+ import type { UsersFacet } from './UsersFacet.js';
74
27
 
75
28
  const DashboardContext = createContext<FougereDashboardContextValue | undefined>(undefined);
76
29
 
@@ -81,28 +34,6 @@ export function useFougereDashboard(): FougereDashboardContextValue {
81
34
  return context;
82
35
  }
83
36
 
84
- export type FougereDashboardZone = 'hero' | 'metrics' | 'main';
85
-
86
- export interface FougereDashboardWidget {
87
- id: string;
88
- zone: FougereDashboardZone;
89
- /** Twelve-column width for `main`, four-column width for `metrics`. */
90
- span: number;
91
- component: ComponentType;
92
- hidden?: boolean;
93
- }
94
-
95
- /** A delta over stable widget ids; a component on a new id contributes a new widget. */
96
- export interface FougereDashboardExtension {
97
- widget: string;
98
- component?: ComponentType;
99
- zone?: FougereDashboardZone;
100
- span?: number;
101
- hidden?: boolean;
102
- before?: string;
103
- after?: string;
104
- }
105
-
106
37
  export function applyDashboardExtensions(
107
38
  defaults: readonly FougereDashboardWidget[],
108
39
  extensions: readonly FougereDashboardExtension[] = [],
@@ -388,7 +319,7 @@ function UsersWidget(): ReactElement {
388
319
  * What the application IS, as opposed to what it holds.
389
320
  *
390
321
  * Every other widget counts rows; this one counts the shape that produced them —
391
- * fronds, doors, operations split by kind, and how wide each entity is. All of it comes
322
+ * fronds, facades, operations split by kind, and how wide each entity is. All of it comes
392
323
  * from the same card the menu was built from, so it costs no query at all.
393
324
  *
394
325
  * **Remotes are not in it, and the card is why.** `identityCardOf` maps `app.fronds`,
@@ -402,7 +333,7 @@ function StructureWidget(): ReactElement {
402
333
  const definitions = useResourceDefinitions();
403
334
 
404
335
  const structure = useMemo(() => {
405
- const byFrond = new Map<string, { doors: number; queries: number; commands: number; fields: number }>();
336
+ const byFrond = new Map<string, { facades: number; queries: number; commands: number; fields: number }>();
406
337
  let queries = 0;
407
338
  let commands = 0;
408
339
  let fields = 0;
@@ -414,9 +345,9 @@ function StructureWidget(): ReactElement {
414
345
  const c = ops.length - q;
415
346
  const width = options?.fieldCount ?? 0;
416
347
  queries += q; commands += c; fields += width;
417
- const held = byFrond.get(frond) ?? { doors: 0, queries: 0, commands: 0, fields: 0 };
348
+ const held = byFrond.get(frond) ?? { facades: 0, queries: 0, commands: 0, fields: 0 };
418
349
  byFrond.set(frond, {
419
- doors: held.doors + 1,
350
+ facades: held.facades + 1,
420
351
  queries: held.queries + q,
421
352
  commands: held.commands + c,
422
353
  fields: held.fields + width,
@@ -424,14 +355,14 @@ function StructureWidget(): ReactElement {
424
355
  }
425
356
  return {
426
357
  fronds: [...byFrond.entries()].map(([name, counts]) => ({ name, ...counts })),
427
- doors: Object.keys(definitions).length,
358
+ facades: Object.keys(definitions).length,
428
359
  queries, commands, fields,
429
360
  };
430
361
  }, [definitions]);
431
362
 
432
363
  const totals: [string, string, number][] = [
433
364
  ['structure.fronds', 'Fronds', structure.fronds.length],
434
- ['structure.doors', 'Doors', structure.doors],
365
+ ['structure.facades', 'Facades', structure.facades],
435
366
  ['structure.queries', 'Queries', structure.queries],
436
367
  ['structure.commands', 'Commands', structure.commands],
437
368
  ];
@@ -468,7 +399,7 @@ function StructureWidget(): ReactElement {
468
399
  <Box sx={{ minWidth: 0 }}>
469
400
  <Typography noWrap sx={{ fontWeight: 640 }}>{frond.name}</Typography>
470
401
  <Typography variant="caption" color="text.secondary">
471
- {t('structure.frondDoors', `${frond.doors} doors`, { smart_count: frond.doors })}
402
+ {t('structure.frondDoors', `${frond.facades} facades`, { smart_count: frond.facades })}
472
403
  {' · '}
473
404
  {t('structure.frondFields', `${frond.fields} fields`, { smart_count: frond.fields })}
474
405
  </Typography>
@@ -495,7 +426,7 @@ function CollectionsWidget(): ReactElement {
495
426
  const { loading, resources, navigate } = useFougereDashboard();
496
427
  return (
497
428
  <Card><CardContent sx={{ p: 2.5, '&:last-child': { pb: 2.5 } }}>
498
- <Typography variant="h5">{t('collections.title', 'Collections')}</Typography><Typography variant="body2" color="text.secondary" sx={{ mt: .4, mb: 2 }}>{t('collections.subtitle', 'Every door the card announced')}</Typography>
429
+ <Typography variant="h5">{t('collections.title', 'Collections')}</Typography><Typography variant="body2" color="text.secondary" sx={{ mt: .4, mb: 2 }}>{t('collections.subtitle', 'Every facade the card announced')}</Typography>
499
430
  <Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: 'repeat(2,minmax(0,1fr))', lg: 'repeat(3,minmax(0,1fr))' }, gap: 1 }}>
500
431
  {loading ? [1, 2, 3].map((key) => <Skeleton key={key} height={58} />) : resources.map((resource) => (
501
432
  <Box key={resource.name} component="button" onClick={() => navigate('list', resource.name)} sx={{ appearance: 'none', width: '100%', display: 'flex', alignItems: 'center', gap: 1.5, p: 1.25, color: 'text.primary', bgcolor: 'transparent', border: 0, borderRadius: 2, textAlign: 'left', cursor: 'pointer', font: 'inherit', '&:hover': { bgcolor: 'action.hover' } }}>
package/src/index.ts CHANGED
@@ -1,9 +1,3 @@
1
- export {
2
- defineAdminExtension,
3
- type AdminExtension,
4
- } from './extensions.js';
5
- export {
6
- type TopologyReport,
7
- type FrondPlacement,
8
- type Edge,
9
- } from './topology.js';
1
+ export { defineAdminExtension, type AdminExtension } from './AdminExtension.js';
2
+ export { type FrondPlacement } from '@fougere/core';
3
+ export { type Edge, type TopologyReport } from '@fougere/core';
package/src/react.tsx CHANGED
@@ -1,3 +1,15 @@
1
+ export type { ReactAdminFieldContext } from './ReactAdminFieldContext.js';
2
+ export type { ReactAdminInputContext } from './ReactAdminInputContext.js';
3
+ export type { ReactAdminFieldRenderer } from './ReactAdminFieldRenderer.js';
4
+ export type { ReactAdminInputRenderer } from './ReactAdminInputRenderer.js';
5
+ export type { ReactAdminRenderers } from './ReactAdminRenderers.js';
6
+ export type { ReactAdminResourceComponents } from './ReactAdminResourceComponents.js';
7
+ export type { ResourceRenderOptions } from './ResourceRenderOptions.js';
8
+ export type { FougereAdminProps } from './FougereAdminProps.js';
9
+ import type { ReactAdminRenderers } from './ReactAdminRenderers.js';
10
+
11
+ import type { ResourceRenderOptions } from './ResourceRenderOptions.js';
12
+ import type { FougereAdminProps } from './FougereAdminProps.js';
1
13
  'use client';
2
14
  /**
3
15
  * The rendering half — a column becomes a field, a form field becomes an input.
@@ -16,24 +28,22 @@ import {
16
28
  } from 'react-admin';
17
29
  import { Route } from 'react-router-dom';
18
30
  import { Box, Button, Card, CardContent, Dialog, DialogActions, DialogContent, DialogTitle, TextField as MuiTextField, Typography } from '@mui/material';
19
- import { cloneElement, useMemo, useState, type ComponentProps, type ComponentType, type ReactElement } from 'react';
20
- import { createAdminRuntime } from './runtime.js';
21
- import type { AdminExtension } from './extensions.js';
22
- import { actionsOf, type AdminOperation, type AdminResource } from './resources.js';
23
- import type { EditorialFacet, UsersFacet } from './facets.js';
24
- import type { Fetcher } from '@fougere/app/client';
31
+ import { cloneElement, useMemo, useState, type ComponentProps, type ReactElement } from 'react';
32
+ import { createAdminRuntime } from './AdminRuntime.js';
33
+ import type { AdminExtension } from './AdminExtension.js';
34
+ import { type AdminOperation } from './AdminOperation.js';
35
+ import { actionsOf, type AdminResource } from './AdminResource.js';
36
+ import type { EditorialFacet } from './EditorialFacet.js';
37
+ import type { UsersFacet } from './UsersFacet.js';
38
+
25
39
  import { formFieldsOf, type FormField, type TableColumn } from '@fougere/app/client';
26
40
  import { Card as SchemaCard } from '@fougere/schema';
27
41
  import { FougereLayout, fougereDarkTheme, fougereLightTheme } from './theme.js';
28
42
  import { FougereTopology } from './topology-page.js';
29
43
  export { FougereTopology, type FougereTopologyProps } from './topology-page.js';
30
- import {
31
- FougereContentIcon,
32
- FougereDashboard,
33
- FougereUsersIcon,
34
- type FougereDashboardExtension,
35
- type FougereResourceOptions,
36
- } from './dashboard.js';
44
+
45
+ import { type FougereResourceOptions } from './FougereResourceOptions.js';
46
+ import { FougereContentIcon, FougereDashboard, FougereUsersIcon } from './dashboard.js';
37
47
 
38
48
  export {
39
49
  FougereAppBar,
@@ -42,21 +52,14 @@ export {
42
52
  fougereDarkTheme,
43
53
  fougereLightTheme,
44
54
  } from './theme.js';
45
- export {
46
- FOUGERE_DASHBOARD_WIDGETS,
47
- FougereContentIcon,
48
- FougereDashboard,
49
- FougereUsersIcon,
50
- applyDashboardExtensions,
51
- useFougereDashboard,
52
- type FougereDashboardContextValue,
53
- type FougereDashboardExtension,
54
- type FougereDashboardMetrics,
55
- type FougereDashboardResource,
56
- type FougereDashboardWidget,
57
- type FougereDashboardZone,
58
- type FougereResourceOptions,
59
- } from './dashboard.js';
55
+ export { type FougereDashboardContextValue } from './FougereDashboardContextValue.js';
56
+ export { type FougereDashboardExtension } from './FougereDashboardExtension.js';
57
+ export { type FougereDashboardMetrics } from './FougereDashboardMetrics.js';
58
+ export { type FougereDashboardResource } from './FougereDashboardResource.js';
59
+ export { type FougereDashboardWidget } from './FougereDashboardWidget.js';
60
+ export { type FougereDashboardZone } from './FougereDashboardZone.js';
61
+ export { type FougereResourceOptions } from './FougereResourceOptions.js';
62
+ export { FOUGERE_DASHBOARD_WIDGETS, FougereContentIcon, FougereDashboard, FougereUsersIcon, applyDashboardExtensions, useFougereDashboard } from './dashboard.js';
60
63
 
61
64
  const FIELDS = {
62
65
  text: TextField, number: NumberField, boolean: BooleanField, date: DateField,
@@ -143,41 +146,6 @@ function defaultInputFor(field: FormField, t: Translate): ReactElement {
143
146
  );
144
147
  }
145
148
 
146
- export interface ReactAdminFieldContext {
147
- resource: AdminResource;
148
- column: TableColumn;
149
- /** The maintained Fougere renderer. Call it to wrap rather than replace it. */
150
- defaultRender(): ReactElement;
151
- }
152
-
153
- export interface ReactAdminInputContext {
154
- resource: AdminResource;
155
- field: FormField;
156
- defaultRender(): ReactElement;
157
- }
158
-
159
- export type ReactAdminFieldRenderer = (context: ReactAdminFieldContext) => ReactElement;
160
- export type ReactAdminInputRenderer = (context: ReactAdminInputContext) => ReactElement;
161
-
162
- export interface ReactAdminRenderers {
163
- /** Exact `resource.field` keys. Unmentioned and future fields keep the default renderer. */
164
- fields?: Record<string, ReactAdminFieldRenderer>;
165
- inputs?: Record<string, ReactAdminInputRenderer>;
166
- }
167
-
168
- export interface ReactAdminResourceComponents {
169
- list?: ComponentType;
170
- show?: ComponentType;
171
- edit?: ComponentType;
172
- create?: ComponentType;
173
- icon?: ComponentType;
174
- }
175
-
176
- export interface ResourceRenderOptions {
177
- renderers?: ReactAdminRenderers;
178
- components?: ReactAdminResourceComponents;
179
- }
180
-
181
149
  function fieldFor(resource: AdminResource, column: TableColumn, t: Translate, renderers?: ReactAdminRenderers): ReactElement {
182
150
  const defaultRender = () => defaultFieldFor(column, t);
183
151
  const renderer = renderers?.fields?.[`${resource.name}.${column.name}`];
@@ -202,7 +170,7 @@ function inputFor(resource: AdminResource, field: FormField, t: Translate, rende
202
170
  * an article from the back-office.
203
171
  *
204
172
  * Three things are derived and none is declared per entity: WHICH ops (everything the
205
- * door serves beyond the five verbs), WHETHER it asks for input (`op.input` — then the
173
+ * facade serves beyond the five verbs), WHETHER it asks for input (`op.input` — then the
206
174
  * dialog's form is `formFieldsOf` over the reconstructed schema, the same projection an
207
175
  * ordinary form uses), and WHETHER it confirms (`op.confirm`, from an extension).
208
176
  */
@@ -286,7 +254,7 @@ function OperationButton({
286
254
  );
287
255
  }
288
256
 
289
- /** Row-level actions: the verbs the door serves, then everything else it serves. */
257
+ /** Row-level actions: the verbs the facade serves, then everything else it serves. */
290
258
  function RowActions({ resource }: { resource: AdminResource }): ReactElement {
291
259
  const actions = useMemo(() => actionsOf(resource.operations), [resource]);
292
260
  return (
@@ -304,9 +272,9 @@ const listFor = (r: AdminResource, renderers?: ReactAdminRenderers) => function
304
272
  const t = useTranslate();
305
273
  return (
306
274
  <List>
307
- {/* `can.delete` had no reader, so a door serving no `delete` still showed the
275
+ {/* `can.delete` had no reader, so a facade serving no `delete` still showed the
308
276
  selection checkboxes and the bulk Delete button — every one of them a 404. And
309
- a door serving `update` but not `findById` had an inert row. */}
277
+ a facade serving `update` but not `findById` had an inert row. */}
310
278
  <Datagrid
311
279
  rowClick={r.can.show ? 'show' : r.can.edit ? 'edit' : false}
312
280
  bulkActionButtons={r.can.delete ? undefined : false}
@@ -353,7 +321,7 @@ const createFor = (r: AdminResource, renderers?: ReactAdminRenderers) => functio
353
321
  return <Create><SimpleForm>{r.fields.map((field) => inputFor(r, field, t, renderers))}</SimpleForm></Create>;
354
322
  };
355
323
 
356
- /** One door, as the four pages the card says it serves. */
324
+ /** One facade, as the four pages the card says it serves. */
357
325
  export function resourceFor(r: AdminResource, options: ResourceRenderOptions = {}): ReactElement {
358
326
  const { renderers, components = {} } = options;
359
327
  const resourceOptions: FougereResourceOptions = {
@@ -403,17 +371,6 @@ export function resourceFor(r: AdminResource, options: ResourceRenderOptions = {
403
371
  */
404
372
  type BaseAdminProps = ComponentProps<typeof Admin>;
405
373
 
406
- export type FougereAdminProps = Omit<BaseAdminProps, 'children' | 'dataProvider'> & {
407
- endpoint?: string;
408
- fetcher?: Fetcher;
409
- extensions?: readonly AdminExtension[];
410
- renderers?: ReactAdminRenderers;
411
- /** Add, move, resize, replace or hide widgets without snapshotting the dashboard. */
412
- dashboardExtensions?: readonly FougereDashboardExtension[];
413
- /** Explicit page-level escape hatches, scoped to one resource and one view. */
414
- resourceComponents?: Record<string, ReactAdminResourceComponents>;
415
- };
416
-
417
374
  const EMPTY_EXTENSIONS: readonly AdminExtension[] = [];
418
375
 
419
376
  /**
@@ -498,7 +455,7 @@ export function FougereAdmin({
498
455
  components: resourceComponents?.[resource.name],
499
456
  })),
500
457
  /*
501
- * The one page that renders the APP rather than a door. It is not a resource —
458
+ * The one page that renders the APP rather than a facade. It is not a resource —
502
459
  * there is no row behind it — so it rides a route, and its data comes from
503
460
  * `rpc.topology` rather than from the card.
504
461
  */
package/src/theme.tsx CHANGED
@@ -501,7 +501,7 @@ const TopologyIcon = (props: SvgIconProps) => (
501
501
  );
502
502
 
503
503
  /**
504
- * The derived menu, plus the one entry that is not a door.
504
+ * The derived menu, plus the one entry that is not a facade.
505
505
  *
506
506
  * `Menu.ResourceItems` keeps the resources the card produced — the menu stays derived, and
507
507
  * this adds beside it rather than replacing it. The topology entry shows even when nothing
@@ -5,18 +5,22 @@
5
5
  *
6
6
  * Every other page in this panel renders a DOOR — a list, a form, a row. This one renders
7
7
  * the app: which fronds run in the process it is talking to, which answered from somewhere
8
- * else, and who called whom. Nothing here is configured, and nothing is declared: a frond
9
- * is remote because it answered a call nobody hosts.
8
+ * else, who called whom — and which the config names but nothing has ever answered from.
9
+ * The first three are OBSERVED: a frond is elsewhere because it answered, never because
10
+ * `remotes:` said so. The fourth is the only declared one, and it is the only one the
11
+ * observed half cannot hold — a frond that never answered is absent from it.
10
12
  *
11
13
  * It is served by `@fougere/observability`, so its absence is a legible state and not an
12
14
  * error — which is why the entry stays in the menu when nothing answers. Hiding it would
13
15
  * hide the one place that can say what is missing.
14
16
  */
15
- import { Box, Card, CardContent, Chip, Skeleton, Typography } from '@mui/material';
16
- import { useEffect, useState, type ReactElement } from 'react';
17
+ import { Box, Card, CardContent, Chip, Skeleton, Typography, type Theme } from '@mui/material';
18
+ import { useEffect, useMemo, useState, type ReactElement } from 'react';
17
19
  import { Title, useTranslate } from 'react-admin';
18
20
  import { CALL_ENDPOINT, fetcher as browserFetcher, type Fetcher } from '@fougere/app/client';
19
- import { fetchTopology, isOpaque, nodesOf, type Edge, type TopologyNode, type TopologyReport } from './topology.js';
21
+ import { type Edge, type TopologyReport } from '@fougere/core';
22
+ import { fetchTopology, figureOf, isOpaque, layoutOf, nodesOf } from './Drawing.js';
23
+ import { type TopologyNode } from './TopologyNode.js';
20
24
 
21
25
  /** What the page is: the report, the refusal that means "not observed", or a real failure. */
22
26
  type State =
@@ -60,6 +64,167 @@ function EdgeLine({ edge, direction }: { edge: Edge; direction: 'out' | 'in' }):
60
64
  );
61
65
  }
62
66
 
67
+ /**
68
+ * A palette path as SVG paint. `sx` resolves `'error.main'` for `color` and `bgcolor` and for
69
+ * nothing else, so `fill` and `stroke` reach CSS verbatim — an invalid value, and SVG falls back
70
+ * to BLACK. Every shape on this page is painted through here.
71
+ */
72
+ function paint(paths: { fill?: string; stroke?: string }) {
73
+ const read = (theme: Theme, path: string) =>
74
+ path.split('.').reduce<unknown>((held, key) => (held as Record<string, unknown>)?.[key], theme.palette) as string;
75
+
76
+ return (theme: Theme) => ({
77
+ ...(paths.fill ? { fill: read(theme, paths.fill) } : {}),
78
+ ...(paths.stroke ? { stroke: read(theme, paths.stroke) } : {}),
79
+ });
80
+ }
81
+
82
+ /** What a node is painted with, by the one thing that separates the three states. */
83
+ function toneOf(node: TopologyNode): 'primary' | 'grey' | 'warning' {
84
+ if (node.silent) return 'warning';
85
+
86
+ return node.placement === 'local' ? 'primary' : 'grey';
87
+ }
88
+
89
+ /**
90
+ * The system, drawn. Placement is `layoutOf`'s — ranks, order and routing.
91
+ *
92
+ * The nodes are HTML over an SVG that draws only the lines, which is how React Flow is built and
93
+ * why its nodes look like the rest of an app: a card is a `Box` with `sx`, so it reads the theme
94
+ * like every other element. An SVG `<text>` positioned by hand does not.
95
+ *
96
+ * Both layers sit on ONE scale — the container takes the drawing's aspect ratio, and every node is
97
+ * sized AND placed in per cent of it. A size in pixels does not grow with the viewBox the SVG
98
+ * scales to its container, and the lines then stop short of the cards they point at.
99
+ *
100
+ * What the drawing says beyond who reaches whom: thickness is volume, a broken line is declared
101
+ * and never travelled, and refusals are a figure rather than a red line — colour alone excludes a
102
+ * reader who cannot see it, and "how many" is the question a red line raises without answering.
103
+ */
104
+ function Graph({ nodes, selected, onSelect }: {
105
+ nodes: readonly TopologyNode[];
106
+ selected: string | undefined;
107
+ onSelect: (frond: string) => void;
108
+ }): ReactElement {
109
+ const label = useLabels();
110
+ const drawing = useMemo(() => layoutOf(nodes), [nodes]);
111
+ const per = (value: number, of: number) => `${(value / of) * 100}%`;
112
+
113
+ return (
114
+ <Card variant="outlined">
115
+ <Box sx={{ position: 'relative', bgcolor: 'action.hover', aspectRatio: `${drawing.width} / ${drawing.height}` }}>
116
+ <Box
117
+ component="svg"
118
+ viewBox={`0 0 ${drawing.width} ${drawing.height}`}
119
+ sx={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}
120
+ >
121
+ <defs>
122
+ <marker id="fougere-arrow" viewBox="0 0 8 8" refX="6.5" refY="4" markerWidth="5" markerHeight="5" orient="auto-start-reverse">
123
+ <Box component="path" d="M 0 0 L 8 4 L 0 8 z" sx={paint({ fill: 'text.disabled' })} />
124
+ </marker>
125
+ </defs>
126
+
127
+ {drawing.edges.map((edge) => (
128
+ <Box
129
+ component="path"
130
+ key={`${edge.from}-${edge.to}`}
131
+ d={edge.path}
132
+ fill="none"
133
+ strokeLinecap="round"
134
+ strokeWidth={edge.weight === undefined ? 1.5 : 1.5 + edge.weight * 3}
135
+ strokeDasharray={edge.weight === undefined ? '2 5' : undefined}
136
+ markerEnd="url(#fougere-arrow)"
137
+ sx={[paint({ stroke: 'text.disabled' }), { strokeOpacity: 0.5 }]}
138
+ />
139
+ ))}
140
+ </Box>
141
+
142
+ {drawing.edges.map((edge) => edge.at && (
143
+ <Box
144
+ key={`figure-${edge.from}-${edge.to}`}
145
+ sx={{
146
+ position: 'absolute',
147
+ left: per(edge.at.x, drawing.width),
148
+ top: per(edge.at.y, drawing.height),
149
+ transform: 'translate(-50%, -50%)',
150
+ px: 0.75,
151
+ borderRadius: 1,
152
+ border: 1,
153
+ borderColor: 'divider',
154
+ bgcolor: 'background.paper',
155
+ fontFamily: 'monospace',
156
+ fontSize: 11,
157
+ fontWeight: 600,
158
+ lineHeight: '17px',
159
+ whiteSpace: 'nowrap',
160
+ color: edge.errors > 0 ? 'error.main' : 'text.secondary',
161
+ }}
162
+ >
163
+ {edge.count === undefined ? label('topology.noTraffic', 'no traffic') : figureOf(edge)}
164
+ </Box>
165
+ ))}
166
+
167
+ {drawing.nodes.map(({ node, x, y, width, height }) => {
168
+ const tone = toneOf(node);
169
+ return (
170
+ <Box
171
+ key={node.frond}
172
+ onClick={() => onSelect(node.frond)}
173
+ sx={{
174
+ position: 'absolute',
175
+ left: per(x, drawing.width),
176
+ top: per(y, drawing.height),
177
+ width: per(width, drawing.width),
178
+ height: per(height, drawing.height),
179
+ transform: 'translate(-50%, -50%)',
180
+ display: 'flex',
181
+ flexDirection: 'column',
182
+ justifyContent: 'center',
183
+ gap: 0.25,
184
+ overflow: 'hidden',
185
+ px: 1.25,
186
+ borderRadius: 1.5,
187
+ border: node.frond === selected ? 2 : 1.4,
188
+ borderStyle: node.silent ? 'dashed' : 'solid',
189
+ borderColor: tone === 'grey' ? 'divider' : `${tone}.main`,
190
+ bgcolor: 'background.paper',
191
+ cursor: 'pointer',
192
+ }}
193
+ >
194
+ <Box sx={{ fontFamily: 'monospace', fontSize: 13.5, fontWeight: 600 }}>{node.frond}</Box>
195
+ <Row>
196
+ {/* A remote's two zeroes are not a measurement: its shape is published by the
197
+ process that owns it, so the card says WHERE it is instead of counting. */}
198
+ {!node.silent && (
199
+ <Typography variant="caption" color="text.secondary" sx={{ fontSize: 10.5 }}>
200
+ {isOpaque(node)
201
+ ? label('topology.elsewhere', 'elsewhere')
202
+ : `${node.entities} · ${node.facades}`}
203
+ </Typography>
204
+ )}
205
+ {tone !== 'grey' && (
206
+ <Chip
207
+ size="small"
208
+ color={tone}
209
+ variant="outlined"
210
+ sx={{ height: 16, '& .MuiChip-label': { px: 0.75, fontSize: 9.5, letterSpacing: '0.05em' } }}
211
+ label={node.silent ? label('topology.unheard', 'unheard') : label('topology.here', 'here')}
212
+ />
213
+ )}
214
+ </Row>
215
+ </Box>
216
+ );
217
+ })}
218
+ </Box>
219
+ <Box sx={{ px: 2, py: 1.25 }}>
220
+ <Typography variant="caption" color="text.secondary">
221
+ {label('topology.legend', 'Thickness is volume. A broken line is declared and never travelled. Click a frond for what it holds.')}
222
+ </Typography>
223
+ </Box>
224
+ </Card>
225
+ );
226
+ }
227
+
63
228
  function FrondCard({ node }: { node: TopologyNode }): ReactElement {
64
229
  const label = useLabels();
65
230
  const here = node.placement === 'local';
@@ -70,8 +235,10 @@ function FrondCard({ node }: { node: TopologyNode }): ReactElement {
70
235
  <Typography variant="h6" sx={{ fontWeight: 700 }}>{node.frond}</Typography>
71
236
  <Chip
72
237
  size="small"
73
- color={here ? 'primary' : 'default'}
74
- label={here ? label('topology.here', 'here') : label('topology.elsewhere', 'elsewhere')}
238
+ color={node.silent ? 'warning' : here ? 'primary' : 'default'}
239
+ label={node.silent
240
+ ? label('topology.unheard', 'declared, unheard')
241
+ : here ? label('topology.here', 'here') : label('topology.elsewhere', 'elsewhere')}
75
242
  />
76
243
  </Row>
77
244
  {/*
@@ -80,10 +247,12 @@ function FrondCard({ node }: { node: TopologyNode }): ReactElement {
80
247
  empty frond, which reads as a frond with nothing in it.
81
248
  */}
82
249
  <Typography variant="body2" color="text.secondary">
83
- {isOpaque(node)
84
- ? label('topology.opaque', 'Its shape is published by the process that owns it.')
85
- : `${label('topology.entities', `${node.entities} entities`, { smart_count: node.entities })}`
86
- + ` · ${label('topology.doors', `${node.doors} doors`, { smart_count: node.doors })}`}
250
+ {node.silent
251
+ ? label('topology.silent', `Declared at ${node.at ?? 'no address'} — nothing has answered from it.`, { at: node.at ?? '' })
252
+ : isOpaque(node)
253
+ ? label('topology.opaque', 'Its shape is published by the process that owns it.')
254
+ : `${label('topology.entities', `${node.entities} entities`, { smart_count: node.entities })}`
255
+ + ` · ${label('topology.facades', `${node.facades} facades`, { smart_count: node.facades })}`}
87
256
  </Typography>
88
257
  {node.calls.map((edge) => <EdgeLine key={`out-${edge.to}`} edge={edge} direction="out" />)}
89
258
  {node.calledBy.map((edge) => <EdgeLine key={`in-${edge.from}`} edge={edge} direction="in" />)}
@@ -100,6 +269,8 @@ export interface FougereTopologyProps {
100
269
  export function FougereTopology({ endpoint = CALL_ENDPOINT, fetcher = browserFetcher }: FougereTopologyProps = {}): ReactElement {
101
270
  const label = useLabels();
102
271
  const [state, setState] = useState<State>({ status: 'loading' });
272
+ /** Which frond the detail below is about — the drawing answers "where", one card answers "what". */
273
+ const [selected, setSelected] = useState<string>();
103
274
 
104
275
  useEffect(() => {
105
276
  let live = true;
@@ -154,18 +325,34 @@ export function FougereTopology({ endpoint = CALL_ENDPOINT, fetcher = browserFet
154
325
  }
155
326
 
156
327
  const nodes = nodesOf(state.report);
157
- const elsewhere = nodes.filter((node) => node.placement === 'remote').length;
328
+ // Three counts because there are three ways of being known, and `elsewhere` means ANSWERED
329
+ // from elsewhere: counting a frond that never answered among them makes the summary
330
+ // contradict the card under it.
331
+ const unheard = nodes.filter((node) => node.silent).length;
332
+ const elsewhere = nodes.filter((node) => node.placement === 'remote' && !node.silent).length;
333
+ const shown = nodes.find((node) => node.frond === selected) ?? nodes[0];
334
+ const travelled = new Set(state.report.edges.map((edge) => `${edge.from} ${edge.to}`));
335
+ const quiet = state.report.declared.edges.filter((edge) => !travelled.has(`${edge.from} ${edge.to}`)).length;
158
336
 
159
337
  return (
160
338
  <Box sx={{ p: 3, display: 'grid', gap: 2 }}>
161
339
  {title}
162
340
  <Row>
163
341
  <Typography variant="body2" color="text.secondary">
164
- {label('topology.here_count', `${nodes.length - elsewhere} here`, { smart_count: nodes.length - elsewhere })}
342
+ {label('topology.here_count', `${nodes.length - elsewhere - unheard} here`, { smart_count: nodes.length - elsewhere - unheard })}
165
343
  {' · '}
166
344
  {label('topology.elsewhere_count', `${elsewhere} elsewhere`, { smart_count: elsewhere })}
345
+ {unheard > 0 && <>
346
+ {' · '}
347
+ {label('topology.unheard_count', `${unheard} unheard`, { smart_count: unheard })}
348
+ </>}
167
349
  {' · '}
168
350
  {label('topology.paths', `${state.report.edges.length} observed call paths`, { smart_count: state.report.edges.length })}
351
+ {/* The two anomalies, counted where a reader looks first — not left to be found in the drawing. */}
352
+ {quiet > 0 && <>
353
+ {' · '}
354
+ {label('topology.quiet_count', `${quiet} link without traffic`, { smart_count: quiet })}
355
+ </>}
169
356
  </Typography>
170
357
  {/* Saturation: the one signal a static shape cannot carry. */}
171
358
  {state.report.active > 0 && (
@@ -176,9 +363,8 @@ export function FougereTopology({ endpoint = CALL_ENDPOINT, fetcher = browserFet
176
363
  />
177
364
  )}
178
365
  </Row>
179
- <Box sx={{ display: 'grid', gap: 2, gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))' }}>
180
- {nodes.map((node) => <FrondCard key={node.frond} node={node} />)}
181
- </Box>
366
+ <Graph nodes={nodes} selected={shown?.frond} onSelect={setSelected} />
367
+ {shown && <Box sx={{ maxWidth: 520 }}><FrondCard node={shown} /></Box>}
182
368
  {/*
183
369
  An edge is only knowable on the side that made the call, so a process with no
184
370
  outgoing call has none — that is ordinary, not a gap in the reading.