@fougere/admin 0.3.0-alpha.0 → 0.4.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.
@@ -0,0 +1,193 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * The shape of the system, drawn from what the app says about itself.
5
+ *
6
+ * Every other page in this panel renders a DOOR — a list, a form, a row. This one renders
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.
10
+ *
11
+ * It is served by `@fougere/observability`, so its absence is a legible state and not an
12
+ * error — which is why the entry stays in the menu when nothing answers. Hiding it would
13
+ * hide the one place that can say what is missing.
14
+ */
15
+ import { Box, Card, CardContent, Chip, Skeleton, Typography } from '@mui/material';
16
+ import { useEffect, useState, type ReactElement } from 'react';
17
+ import { Title, useTranslate } from 'react-admin';
18
+ 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';
20
+
21
+ /** What the page is: the report, the refusal that means "not observed", or a real failure. */
22
+ type State =
23
+ | { status: 'loading' }
24
+ | { status: 'served'; report: TopologyReport }
25
+ | { status: 'unobserved' }
26
+ | { status: 'failed'; error: unknown };
27
+
28
+ /**
29
+ * Every sentence this page draws goes through a key. Nothing under `@fougere/admin` is
30
+ * written in a language — the counts below carry `smart_count`, which is what lets a
31
+ * translation pick its own plural rather than inherit English's.
32
+ */
33
+ function useLabels() {
34
+ const translate = useTranslate();
35
+ return (key: string, fallback: string, options?: Record<string, unknown>) =>
36
+ translate(`fougere.admin.${key}`, { _: fallback, ...options });
37
+ }
38
+
39
+ const Row = ({ children }: { children: React.ReactNode }) => (
40
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>{children}</Box>
41
+ );
42
+
43
+ /** One call path, with its refusals — the only number here that is a health signal. */
44
+ function EdgeLine({ edge, direction }: { edge: Edge; direction: 'out' | 'in' }): ReactElement {
45
+ const label = useLabels();
46
+ return (
47
+ <Row>
48
+ <Typography variant="caption" sx={{ fontFamily: 'monospace' }}>
49
+ {direction === 'out' ? `→ ${edge.to}` : `← ${edge.from}`}
50
+ </Typography>
51
+ <Typography variant="caption" color="text.secondary">{edge.count}</Typography>
52
+ {edge.errors > 0 && (
53
+ <Chip
54
+ size="small"
55
+ color="error"
56
+ label={label('topology.refused', `${edge.errors} refused`, { smart_count: edge.errors })}
57
+ />
58
+ )}
59
+ </Row>
60
+ );
61
+ }
62
+
63
+ function FrondCard({ node }: { node: TopologyNode }): ReactElement {
64
+ const label = useLabels();
65
+ const here = node.placement === 'local';
66
+ return (
67
+ <Card variant="outlined">
68
+ <CardContent sx={{ display: 'grid', gap: 1 }}>
69
+ <Row>
70
+ <Typography variant="h6" sx={{ fontWeight: 700 }}>{node.frond}</Typography>
71
+ <Chip
72
+ size="small"
73
+ color={here ? 'primary' : 'default'}
74
+ label={here ? label('topology.here', 'here') : label('topology.elsewhere', 'elsewhere')}
75
+ />
76
+ </Row>
77
+ {/*
78
+ A remote publishes its own shape under its own service name, so this panel can say
79
+ the frond is reachable and cannot say what it holds. Naming that beats drawing an
80
+ empty frond, which reads as a frond with nothing in it.
81
+ */}
82
+ <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 })}`}
87
+ </Typography>
88
+ {node.calls.map((edge) => <EdgeLine key={`out-${edge.to}`} edge={edge} direction="out" />)}
89
+ {node.calledBy.map((edge) => <EdgeLine key={`in-${edge.from}`} edge={edge} direction="in" />)}
90
+ </CardContent>
91
+ </Card>
92
+ );
93
+ }
94
+
95
+ export interface FougereTopologyProps {
96
+ endpoint?: string;
97
+ fetcher?: Fetcher;
98
+ }
99
+
100
+ export function FougereTopology({ endpoint = CALL_ENDPOINT, fetcher = browserFetcher }: FougereTopologyProps = {}): ReactElement {
101
+ const label = useLabels();
102
+ const [state, setState] = useState<State>({ status: 'loading' });
103
+
104
+ useEffect(() => {
105
+ let live = true;
106
+ fetchTopology(endpoint, fetcher)
107
+ .then((report) => { if (live) setState(report ? { status: 'served', report } : { status: 'unobserved' }); })
108
+ .catch((error: unknown) => { if (live) setState({ status: 'failed', error }); });
109
+ return () => { live = false; };
110
+ }, [endpoint, fetcher]);
111
+
112
+ const title = <Title title={label('topology.title', 'Topology')} />;
113
+
114
+ if (state.status === 'loading') {
115
+ return <Box sx={{ p: 3 }}>{title}<Skeleton height={120} /><Skeleton height={120} /></Box>;
116
+ }
117
+
118
+ if (state.status === 'unobserved') {
119
+ return (
120
+ <Box sx={{ p: 3, maxWidth: 620 }}>
121
+ {title}
122
+ <Card>
123
+ <CardContent sx={{ display: 'grid', gap: 1.5 }}>
124
+ <Typography variant="h6">{label('topology.unobservedTitle', 'This app is not observing itself')}</Typography>
125
+ <Typography variant="body2" color="text.secondary">
126
+ {label(
127
+ 'topology.unobservedBody',
128
+ 'The shape of a system is read from inside the process it describes, and this one publishes none. Install @fougere/observability and declare it as an extension of the boot.',
129
+ )}
130
+ </Typography>
131
+ <Typography component="pre" variant="caption" sx={{ p: 1.5, borderRadius: 1.5, bgcolor: 'action.hover', overflowX: 'auto' }}>
132
+ {"import { observability } from '@fougere/observability';\n\nextensions: [observability()]"}
133
+ </Typography>
134
+ </CardContent>
135
+ </Card>
136
+ </Box>
137
+ );
138
+ }
139
+
140
+ if (state.status === 'failed') {
141
+ return (
142
+ <Box sx={{ p: 3, maxWidth: 620 }}>
143
+ {title}
144
+ <Card>
145
+ <CardContent sx={{ display: 'grid', gap: 1.5 }}>
146
+ <Typography variant="h6">{label('topology.failedTitle', 'The app did not answer')}</Typography>
147
+ <Typography component="pre" variant="caption" sx={{ p: 1.5, borderRadius: 1.5, bgcolor: 'action.hover', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
148
+ {(state.error as Error)?.message ?? String(state.error)}
149
+ </Typography>
150
+ </CardContent>
151
+ </Card>
152
+ </Box>
153
+ );
154
+ }
155
+
156
+ const nodes = nodesOf(state.report);
157
+ const elsewhere = nodes.filter((node) => node.placement === 'remote').length;
158
+
159
+ return (
160
+ <Box sx={{ p: 3, display: 'grid', gap: 2 }}>
161
+ {title}
162
+ <Row>
163
+ <Typography variant="body2" color="text.secondary">
164
+ {label('topology.here_count', `${nodes.length - elsewhere} here`, { smart_count: nodes.length - elsewhere })}
165
+ {' · '}
166
+ {label('topology.elsewhere_count', `${elsewhere} elsewhere`, { smart_count: elsewhere })}
167
+ {' · '}
168
+ {label('topology.paths', `${state.report.edges.length} observed call paths`, { smart_count: state.report.edges.length })}
169
+ </Typography>
170
+ {/* Saturation: the one signal a static shape cannot carry. */}
171
+ {state.report.active > 0 && (
172
+ <Chip
173
+ size="small"
174
+ color="primary"
175
+ label={label('topology.inFlight', `${state.report.active} in flight`, { smart_count: state.report.active })}
176
+ />
177
+ )}
178
+ </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>
182
+ {/*
183
+ An edge is only knowable on the side that made the call, so a process with no
184
+ outgoing call has none — that is ordinary, not a gap in the reading.
185
+ */}
186
+ {state.report.edges.length === 0 && (
187
+ <Typography variant="caption" color="text.secondary">
188
+ {label('topology.noEdges', 'No call between fronds observed yet — an edge appears the first time one calls another.')}
189
+ </Typography>
190
+ )}
191
+ </Box>
192
+ );
193
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * How the system is arranged, as the app itself reports it.
3
+ *
4
+ * The card answers what a process HOSTS; this answers what SHAPE it is in — which fronds
5
+ * run here, which answered from somewhere else, and who called whom. It is served by
6
+ * `@fougere/observability` on `rpc.topology`, so absence is an ordinary answer: an app
7
+ * that never installed the package has no topology to publish, and says so by refusing
8
+ * the op. Nothing here is declared — a frond is remote because it answered a call nobody
9
+ * hosts, never because a config line said so.
10
+ */
11
+ import {
12
+ CALL_ENDPOINT,
13
+ fetcher as browserFetcher,
14
+ sendCall,
15
+ type Fetcher,
16
+ } from '@fougere/app/client';
17
+ import {
18
+ EMPTY_INVOCATION,
19
+ ErrorCode,
20
+ type Edge,
21
+ type FrondPlacement,
22
+ type TopologyReport,
23
+ } from '@fougere/core/contract';
24
+
25
+ export type { TopologyReport, FrondPlacement, Edge };
26
+
27
+ /**
28
+ * The report, or `undefined` when the app serves no topology at all.
29
+ *
30
+ * The refusal is DISTINGUISHED, not swallowed: a `NOT_FOUND` on the op is the package not
31
+ * being wired — something the panel can explain — while anything else is a real failure
32
+ * and stays a failure. One reserved op that is absent must not read as an app that is down.
33
+ */
34
+ export async function fetchTopology(
35
+ endpoint = CALL_ENDPOINT,
36
+ fetcher: Fetcher = browserFetcher,
37
+ ): Promise<TopologyReport | undefined> {
38
+ try {
39
+ return await sendCall(
40
+ fetcher,
41
+ { entity: 'rpc', op: 'topology' },
42
+ EMPTY_INVOCATION,
43
+ endpoint,
44
+ ) as TopologyReport;
45
+ } catch (error) {
46
+ if ((error as { code?: unknown })?.code === ErrorCode.NOT_FOUND) return undefined;
47
+ throw error;
48
+ }
49
+ }
50
+
51
+ /** One frond as the page draws it: its placement, and the calls observed around it. */
52
+ export interface TopologyNode extends FrondPlacement {
53
+ /** Fronds this one called, with what it cost them. */
54
+ calls: Edge[];
55
+ /** Fronds that called this one. */
56
+ calledBy: Edge[];
57
+ }
58
+
59
+ /**
60
+ * The report read as a graph, local fronds first.
61
+ *
62
+ * Two passes over `edges` rather than one index per direction: a topology is bounded by
63
+ * fronds², so the whole thing is smaller than one page of rows and an index would be a
64
+ * second structure to keep true.
65
+ */
66
+ export function nodesOf(report: TopologyReport): TopologyNode[] {
67
+ const order = { local: 0, remote: 1 };
68
+ return [...report.fronds]
69
+ .sort((a, b) => order[a.placement] - order[b.placement] || a.frond.localeCompare(b.frond))
70
+ .map((frond) => ({
71
+ ...frond,
72
+ calls: report.edges.filter((edge) => edge.from === frond.frond),
73
+ calledBy: report.edges.filter((edge) => edge.to === frond.frond),
74
+ }));
75
+ }
76
+
77
+ /**
78
+ * A frond this process called that publishes nothing of its own shape.
79
+ *
80
+ * The honest reading of `entities: 0, doors: 0` on a remote: its shape is published by the
81
+ * process that owns it, under its own service name — so a panel pointed here can say the
82
+ * frond is reachable and cannot say what it holds. Worth naming rather than drawing as an
83
+ * empty frond, which reads as a frond with nothing in it.
84
+ */
85
+ export function isOpaque(node: FrondPlacement): boolean {
86
+ return node.placement === 'remote' && node.entities === 0 && node.doors === 0;
87
+ }