@startsimpli/integrations 0.2.22 → 0.2.24
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.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/integrations",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.24",
|
|
4
4
|
"description": "Provider-agnostic types, API clients, hooks, and UI primitives for email + calendar integrations across StartSimpli apps.",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"react-dom": "^19.0.0",
|
|
49
49
|
"typescript": "^6.0.0",
|
|
50
50
|
"vitest": "^3.0.0",
|
|
51
|
-
"@startsimpli/llm": "0.4.
|
|
51
|
+
"@startsimpli/llm": "0.4.18"
|
|
52
52
|
},
|
|
53
53
|
"scripts": {
|
|
54
54
|
"type-check": "tsc --noEmit",
|
|
@@ -997,3 +997,113 @@ describe('ConnectionsPage — foundry mode is untouched by personal mode', () =>
|
|
|
997
997
|
expect(within(section).getByRole('button', { name: /^Save$/i })).toBeInTheDocument();
|
|
998
998
|
});
|
|
999
999
|
});
|
|
1000
|
+
|
|
1001
|
+
// --------------------------------------------------------------------------
|
|
1002
|
+
// the members' roster: a verb only where it still does something
|
|
1003
|
+
// (bd startsim-768w.22.25.19)
|
|
1004
|
+
// --------------------------------------------------------------------------
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* One row per status, because the whole defect was a button that never read
|
|
1008
|
+
* the status. `disabled` is the row AFTER a disconnect — key deleted, tokens
|
|
1009
|
+
* revoked, fetches stopped. `error` and `needs_reauth` still hold a stored
|
|
1010
|
+
* credential and a live poll leg, and they are the rows an admin offboarding
|
|
1011
|
+
* somebody most needs to cut, so the suppression has to be narrow enough to
|
|
1012
|
+
* leave them alone.
|
|
1013
|
+
*/
|
|
1014
|
+
const MEMBER_ROWS = [
|
|
1015
|
+
{
|
|
1016
|
+
id: 'm-live', owner: { id: 'u1', email: 'ana@acme.test', name: 'Ana' },
|
|
1017
|
+
connector_key: 'n8n', connector_name: 'n8n', name: 'Ana n8n',
|
|
1018
|
+
account_label: 'ana@acme.test', status: 'connected', health: 'ok',
|
|
1019
|
+
},
|
|
1020
|
+
{
|
|
1021
|
+
id: 'm-broken', owner: { id: 'u2', email: 'bo@acme.test', name: 'Bo' },
|
|
1022
|
+
connector_key: 'granola', connector_name: 'Granola', name: 'Bo Granola',
|
|
1023
|
+
account_label: 'bo@acme.test', status: 'error', health: 'failing',
|
|
1024
|
+
check_error: 'Vendor said 401',
|
|
1025
|
+
},
|
|
1026
|
+
{
|
|
1027
|
+
id: 'm-stale', owner: { id: 'u3', email: 'cy@acme.test', name: 'Cy' },
|
|
1028
|
+
connector_key: 'granola', connector_name: 'Granola', name: 'Cy Granola',
|
|
1029
|
+
account_label: 'cy@acme.test', status: 'needs_reauth', health: 'expired',
|
|
1030
|
+
},
|
|
1031
|
+
{
|
|
1032
|
+
id: 'm-gone', owner: { id: 'u4', email: 'di@acme.test', name: 'Di' },
|
|
1033
|
+
connector_key: 'n8n', connector_name: 'n8n', name: 'Di n8n',
|
|
1034
|
+
account_label: 'di@acme.test', status: 'disabled', health: 'disconnected',
|
|
1035
|
+
},
|
|
1036
|
+
];
|
|
1037
|
+
|
|
1038
|
+
function rosterApi(overrides: Partial<ConnectionsApi> = {}): ConnectionsApi {
|
|
1039
|
+
return stubApi({
|
|
1040
|
+
memberConnections: vi.fn().mockResolvedValue({
|
|
1041
|
+
results: MEMBER_ROWS,
|
|
1042
|
+
permissions: { view: true, manage: true, issue_tokens: false },
|
|
1043
|
+
}),
|
|
1044
|
+
disconnectMemberConnection: vi.fn().mockResolvedValue(undefined),
|
|
1045
|
+
...overrides,
|
|
1046
|
+
} as Partial<ConnectionsApi>);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/** The card element for one member row, by the id the roster stamps on it. */
|
|
1050
|
+
async function memberCard(id: string): Promise<HTMLElement> {
|
|
1051
|
+
return waitFor(() => {
|
|
1052
|
+
const el = document.querySelector<HTMLElement>(`div[data-member-connection="${id}"]`);
|
|
1053
|
+
if (!el) throw new Error(`no member row rendered for ${id}`);
|
|
1054
|
+
return el;
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
describe('MemberConnectionRoster — Disconnect is not offered twice', () => {
|
|
1059
|
+
it('does NOT offer Disconnect on a row that is already disconnected', async () => {
|
|
1060
|
+
render(<ConnectionsPage api={rosterApi()} foundrySlug="acme" />);
|
|
1061
|
+
const gone = await memberCard('m-gone');
|
|
1062
|
+
expect(within(gone).queryByRole('button', { name: /Disconnect/i })).toBeNull();
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
it('says the account is gone and that only the member can bring it back', async () => {
|
|
1066
|
+
// The row STAYS. Who used to hold a pipe into this foundry is the answer an
|
|
1067
|
+
// offboarding review is looking for; hiding it would lose that.
|
|
1068
|
+
render(<ConnectionsPage api={rosterApi()} foundrySlug="acme" />);
|
|
1069
|
+
const gone = await memberCard('m-gone');
|
|
1070
|
+
const note = gone.querySelector('[data-member-connection-disconnected]');
|
|
1071
|
+
expect(note).not.toBeNull();
|
|
1072
|
+
expect(note!.textContent).toMatch(/Disconnected\./);
|
|
1073
|
+
expect(note!.textContent).toMatch(/di@acme\.test has to connect with n8n again/);
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
it('never claims a deleted key "cannot be undone"', async () => {
|
|
1077
|
+
// The exact copy the bead objects to, over a key that is already gone.
|
|
1078
|
+
render(<ConnectionsPage api={rosterApi()} foundrySlug="acme" />);
|
|
1079
|
+
const gone = await memberCard('m-gone');
|
|
1080
|
+
expect(within(gone).queryByText(/cannot be undone/i)).toBeNull();
|
|
1081
|
+
expect(within(gone).queryByText(/stored key is deleted and their/i)).toBeNull();
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
it('KEEPS Disconnect on a failing row, whose key is still stored', async () => {
|
|
1085
|
+
render(<ConnectionsPage api={rosterApi()} foundrySlug="acme" />);
|
|
1086
|
+
const broken = await memberCard('m-broken');
|
|
1087
|
+
expect(within(broken).getByRole('button', { name: /^Disconnect$/i }))
|
|
1088
|
+
.toBeInTheDocument();
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
it('KEEPS Disconnect on a needs_reauth row, whose poll leg is still live', async () => {
|
|
1092
|
+
render(<ConnectionsPage api={rosterApi()} foundrySlug="acme" />);
|
|
1093
|
+
const stale = await memberCard('m-stale');
|
|
1094
|
+
expect(within(stale).getByRole('button', { name: /^Disconnect$/i }))
|
|
1095
|
+
.toBeInTheDocument();
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
it('still cuts a connected row, naming the person before it does', async () => {
|
|
1099
|
+
const api = rosterApi();
|
|
1100
|
+
render(<ConnectionsPage api={api} foundrySlug="acme" />);
|
|
1101
|
+
const live = await memberCard('m-live');
|
|
1102
|
+
await userEvent.click(within(live).getByRole('button', { name: /^Disconnect$/i }));
|
|
1103
|
+
const prompt = within(live).getByText(/cannot be undone/i);
|
|
1104
|
+
expect(prompt.textContent).toContain('ana@acme.test');
|
|
1105
|
+
await userEvent.click(within(live).getByRole('button', { name: /Yes, disconnect/i }));
|
|
1106
|
+
await waitFor(() =>
|
|
1107
|
+
expect(api.disconnectMemberConnection).toHaveBeenCalledWith('acme', 'm-live'));
|
|
1108
|
+
});
|
|
1109
|
+
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THREE FACTS, NOT ONE (bd startsim-1cow).
|
|
3
|
+
*
|
|
4
|
+
* `SubtreeConnectionsView` in central annotates its rows with three deliberately
|
|
5
|
+
* distinct timestamps and says so in the source:
|
|
6
|
+
*
|
|
7
|
+
* last_synced_at the last run that actually brought data in
|
|
8
|
+
* last_attempt_at the last run of ANY outcome — so "it tried ten minutes ago
|
|
9
|
+
* and failed" is expressible, and a card cannot read
|
|
10
|
+
* "nothing yet" while it is in fact failing on a schedule
|
|
11
|
+
* last_checked_at when the CREDENTIAL was last verified, "a far more
|
|
12
|
+
* optimistic fact than either and never used as a stand-in"
|
|
13
|
+
*
|
|
14
|
+
* `listSources()` used to coalesce all three with `??`. Central sends
|
|
15
|
+
* `last_synced_at: null` for a connection that has never delivered a record, so
|
|
16
|
+
* the chain fell straight through to `last_checked_at` and a connection whose
|
|
17
|
+
* KEY was verified five minutes ago rendered as data that ARRIVED five minutes
|
|
18
|
+
* ago. That is the one thing a freshness promise may not do.
|
|
19
|
+
*
|
|
20
|
+
* The tenant proxies this endpoint VERBATIM — `tenant-starter`'s
|
|
21
|
+
* `ConnectionsView` returns `SubtreeClient.connections()` untouched, and that
|
|
22
|
+
* client is a plain `json.loads` of the central body — so every field asserted
|
|
23
|
+
* below really is on the wire this adapter reads, in the snake_case central
|
|
24
|
+
* wrote it. `@startsimpli/api`'s fetch wrapper camelCases response keys in the
|
|
25
|
+
* browser and not from a server-side fetch, which is why every field is read in
|
|
26
|
+
* BOTH spellings and both spellings are exercised here.
|
|
27
|
+
*/
|
|
28
|
+
import { describe, expect, it } from 'vitest';
|
|
29
|
+
import { render, screen } from '@testing-library/react';
|
|
30
|
+
|
|
31
|
+
import { LibraryPage } from '../components/pages/LibraryPage';
|
|
32
|
+
import { createCorpusApi } from '../corpus/client';
|
|
33
|
+
import type { CorpusHttp } from '../corpus/client';
|
|
34
|
+
import { FIXTURE_ORG } from '../corpus/fixtures';
|
|
35
|
+
|
|
36
|
+
const SCOPE = { orgId: FIXTURE_ORG };
|
|
37
|
+
const EMPTY_PAGE = { count: 0, next: null, previous: null, results: [] };
|
|
38
|
+
|
|
39
|
+
// Anchored on the real clock so the staleness window is decided by the offset
|
|
40
|
+
// and never by what day the suite happens to run.
|
|
41
|
+
const NOW = Date.now();
|
|
42
|
+
const ago = (ms: number) => new Date(NOW - ms).toISOString();
|
|
43
|
+
const FIVE_MINUTES_AGO = ago(5 * 60_000);
|
|
44
|
+
const TEN_MINUTES_AGO = ago(10 * 60_000);
|
|
45
|
+
const AN_HOUR_AGO = ago(60 * 60_000);
|
|
46
|
+
const A_WEEK_AGO = ago(7 * 24 * 60 * 60_000);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The WIRE, not the contract. Rows go in exactly as central serialises them so a
|
|
50
|
+
* fixture can never be more generous than the server that feeds it.
|
|
51
|
+
*/
|
|
52
|
+
function httpOver(rows: Array<Record<string, unknown>>): CorpusHttp {
|
|
53
|
+
return {
|
|
54
|
+
async get<T>(path: string): Promise<T> {
|
|
55
|
+
if (path.includes('connections')) {
|
|
56
|
+
return { count: rows.length, next: null, previous: null, results: rows } as T;
|
|
57
|
+
}
|
|
58
|
+
return EMPTY_PAGE as T;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function sourcesFrom(rows: Array<Record<string, unknown>>, staleAfterHours = 26) {
|
|
64
|
+
const api = createCorpusApi({ http: httpOver(rows), scope: SCOPE, staleAfterHours });
|
|
65
|
+
return api.listSources();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A connected row with nothing wrong with it and nothing delivered by it. */
|
|
69
|
+
const CONNECTED = {
|
|
70
|
+
id: 'c1',
|
|
71
|
+
connector_key: 'granola',
|
|
72
|
+
connector_name: 'Granola',
|
|
73
|
+
status: 'connected',
|
|
74
|
+
check_error: '',
|
|
75
|
+
last_synced_at: null,
|
|
76
|
+
last_attempt_at: null,
|
|
77
|
+
last_checked_at: null,
|
|
78
|
+
consecutive_failures: 0,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
describe('listSources — a verified credential is not delivered data', () => {
|
|
82
|
+
it('reports never for a connection whose key checked out but which has never synced', async () => {
|
|
83
|
+
const [source] = await sourcesFrom([
|
|
84
|
+
{ ...CONNECTED, last_checked_at: FIVE_MINUTES_AGO },
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
expect(source.status).toBe('never');
|
|
88
|
+
// The stand-in central forbids: `last_checked_at` may not become the sync
|
|
89
|
+
// time. Asserting the status alone would let the timestamp keep leaking into
|
|
90
|
+
// the visible "synced 5 min ago" line.
|
|
91
|
+
expect(source.lastSyncedAt).toBeNull();
|
|
92
|
+
expect(source.lastCheckedAt).toBe(FIVE_MINUTES_AGO);
|
|
93
|
+
expect(source.detail).toMatch(/nothing has arrived/i);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('reports never in the camelCase spelling too, which is what the browser sees', async () => {
|
|
97
|
+
const [source] = await sourcesFrom([
|
|
98
|
+
{
|
|
99
|
+
id: 'c1',
|
|
100
|
+
connectorKey: 'granola',
|
|
101
|
+
connectorName: 'Granola',
|
|
102
|
+
status: 'connected',
|
|
103
|
+
checkError: '',
|
|
104
|
+
lastSyncedAt: null,
|
|
105
|
+
lastAttemptAt: null,
|
|
106
|
+
lastCheckedAt: FIVE_MINUTES_AGO,
|
|
107
|
+
consecutiveFailures: 0,
|
|
108
|
+
},
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
expect(source.status).toBe('never');
|
|
112
|
+
expect(source.lastSyncedAt).toBeNull();
|
|
113
|
+
expect(source.lastCheckedAt).toBe(FIVE_MINUTES_AGO);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('keeps the three facts apart when all three are present and different', async () => {
|
|
117
|
+
const [source] = await sourcesFrom([
|
|
118
|
+
{
|
|
119
|
+
...CONNECTED,
|
|
120
|
+
last_synced_at: AN_HOUR_AGO,
|
|
121
|
+
last_attempt_at: AN_HOUR_AGO,
|
|
122
|
+
last_checked_at: FIVE_MINUTES_AGO,
|
|
123
|
+
},
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
expect(source.lastSyncedAt).toBe(AN_HOUR_AGO);
|
|
127
|
+
expect(source.lastAttemptAt).toBe(AN_HOUR_AGO);
|
|
128
|
+
expect(source.lastCheckedAt).toBe(FIVE_MINUTES_AGO);
|
|
129
|
+
expect(source.status).toBe('ok');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('measures staleness against the SYNC, never against the credential check', async () => {
|
|
133
|
+
const [source] = await sourcesFrom([
|
|
134
|
+
{
|
|
135
|
+
...CONNECTED,
|
|
136
|
+
last_synced_at: A_WEEK_AGO,
|
|
137
|
+
last_attempt_at: A_WEEK_AGO,
|
|
138
|
+
// Checked minutes ago and still a week stale — the credential being fine
|
|
139
|
+
// is exactly why nobody noticed.
|
|
140
|
+
last_checked_at: FIVE_MINUTES_AGO,
|
|
141
|
+
},
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
expect(source.status).toBe('stale');
|
|
145
|
+
expect(source.lastSyncedAt).toBe(A_WEEK_AGO);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('listSources — it tried and it failed', () => {
|
|
150
|
+
it('does not say "nothing yet" for a connection that is failing on a schedule', async () => {
|
|
151
|
+
const [source] = await sourcesFrom([
|
|
152
|
+
{
|
|
153
|
+
...CONNECTED,
|
|
154
|
+
last_synced_at: null,
|
|
155
|
+
// A run FINISHED ten minutes ago and none has ever finished well, which
|
|
156
|
+
// is exactly how central expresses a failed run.
|
|
157
|
+
last_attempt_at: TEN_MINUTES_AGO,
|
|
158
|
+
last_checked_at: FIVE_MINUTES_AGO,
|
|
159
|
+
},
|
|
160
|
+
]);
|
|
161
|
+
|
|
162
|
+
expect(source.status).toBe('error');
|
|
163
|
+
expect(source.lastSyncedAt).toBeNull();
|
|
164
|
+
expect(source.lastAttemptAt).toBe(TEN_MINUTES_AGO);
|
|
165
|
+
expect(source.detail).toMatch(/sync/i);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('calls a run of consecutive failures failing even after a good sync', async () => {
|
|
169
|
+
const [source] = await sourcesFrom([
|
|
170
|
+
{
|
|
171
|
+
...CONNECTED,
|
|
172
|
+
last_synced_at: AN_HOUR_AGO,
|
|
173
|
+
last_attempt_at: TEN_MINUTES_AGO,
|
|
174
|
+
consecutive_failures: 3,
|
|
175
|
+
},
|
|
176
|
+
]);
|
|
177
|
+
|
|
178
|
+
expect(source.status).toBe('error');
|
|
179
|
+
expect(source.lastSyncedAt).toBe(AN_HOUR_AGO);
|
|
180
|
+
expect(source.consecutiveFailures).toBe(3);
|
|
181
|
+
expect(source.detail).toMatch(/3/);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('reads the failure counter in the camelCase spelling as well', async () => {
|
|
185
|
+
const [source] = await sourcesFrom([
|
|
186
|
+
{
|
|
187
|
+
id: 'c1',
|
|
188
|
+
connectorKey: 'granola',
|
|
189
|
+
connectorName: 'Granola',
|
|
190
|
+
status: 'connected',
|
|
191
|
+
lastSyncedAt: AN_HOUR_AGO,
|
|
192
|
+
lastAttemptAt: TEN_MINUTES_AGO,
|
|
193
|
+
consecutiveFailures: 2,
|
|
194
|
+
},
|
|
195
|
+
]);
|
|
196
|
+
|
|
197
|
+
expect(source.status).toBe('error');
|
|
198
|
+
expect(source.consecutiveFailures).toBe(2);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('still lets the server’s own error text win, in the words the server used', async () => {
|
|
202
|
+
const [source] = await sourcesFrom([
|
|
203
|
+
{
|
|
204
|
+
...CONNECTED,
|
|
205
|
+
status: 'needs_reauth',
|
|
206
|
+
check_error: 'The vendor rejected this key (401). Reconnect Granola.',
|
|
207
|
+
last_attempt_at: TEN_MINUTES_AGO,
|
|
208
|
+
consecutive_failures: 4,
|
|
209
|
+
},
|
|
210
|
+
]);
|
|
211
|
+
|
|
212
|
+
expect(source.status).toBe('error');
|
|
213
|
+
expect(source.detail).toBe('The vendor rejected this key (401). Reconnect Granola.');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('says nothing has arrived when nothing has ever even been tried', async () => {
|
|
217
|
+
const [source] = await sourcesFrom([{ ...CONNECTED }]);
|
|
218
|
+
|
|
219
|
+
expect(source.status).toBe('never');
|
|
220
|
+
expect(source.lastSyncedAt).toBeNull();
|
|
221
|
+
expect(source.lastAttemptAt).toBeNull();
|
|
222
|
+
expect(source.detail).toBe('Connected, but nothing has arrived yet.');
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe('the Library strip, rendered', () => {
|
|
227
|
+
/**
|
|
228
|
+
* The bug was VISIBLE, so one assertion is made where the reader would have
|
|
229
|
+
* seen it: the strip prints `synced {relative(lastSyncedAt)}`, and a
|
|
230
|
+
* never-synced connection with a freshly verified key used to render "synced
|
|
231
|
+
* 5 min ago" over an empty corpus.
|
|
232
|
+
*/
|
|
233
|
+
it('does not report a never-synced source as recently synced', async () => {
|
|
234
|
+
const http = httpOver([{ ...CONNECTED, last_checked_at: FIVE_MINUTES_AGO }]);
|
|
235
|
+
const api = createCorpusApi({ http, scope: SCOPE });
|
|
236
|
+
|
|
237
|
+
render(<LibraryPage api={api} scope={SCOPE} />);
|
|
238
|
+
|
|
239
|
+
expect(await screen.findByText('Nothing yet')).toBeInTheDocument();
|
|
240
|
+
expect(screen.getByText(/synced never/)).toBeInTheDocument();
|
|
241
|
+
expect(screen.queryByText(/synced (just now|\d)/)).not.toBeInTheDocument();
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -108,6 +108,19 @@ function MemberConnectionCard({
|
|
|
108
108
|
const [error, setError] = React.useState('');
|
|
109
109
|
const who = row.owner?.email || row.owner?.name || 'Unknown member';
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* ALREADY CUT (bd startsim-768w.22.25.19). `disabled` is the status a row
|
|
113
|
+
* carries AFTER a disconnect: the key is deleted, the tokens are revoked and
|
|
114
|
+
* the scheduled fetches are stopped. There is nothing left for this verb to
|
|
115
|
+
* do, so offering it — and then confirming with "this cannot be undone" over
|
|
116
|
+
* a key that is already gone — describes an action the server would refuse.
|
|
117
|
+
*
|
|
118
|
+
* ONLY `disabled`. `error` and `needs_reauth` still hold a stored credential
|
|
119
|
+
* and a live poll leg; they are exactly the rows an admin offboarding someone
|
|
120
|
+
* most needs to cut, so the test for this asserts they keep the button.
|
|
121
|
+
*/
|
|
122
|
+
const alreadyDisconnected = row.status === 'disabled';
|
|
123
|
+
|
|
111
124
|
async function cut() {
|
|
112
125
|
if (!disconnect) return;
|
|
113
126
|
setBusy(true);
|
|
@@ -143,8 +156,18 @@ function MemberConnectionCard({
|
|
|
143
156
|
anything the member submitted. An admin deciding whether to cut a
|
|
144
157
|
pipe is entitled to know it is already broken. */}
|
|
145
158
|
{row.check_error ? <p className={cn.error}>{row.check_error}</p> : null}
|
|
159
|
+
{/* THE ROW STAYS. Who USED to have a pipe into this foundry is the
|
|
160
|
+
answer an offboarding review is looking for, so a disconnected
|
|
161
|
+
account is not hidden — it is labelled, and its verb is gone. */}
|
|
162
|
+
{alreadyDisconnected ? (
|
|
163
|
+
<p className={cn.muted} data-member-connection-disconnected>
|
|
164
|
+
Disconnected. This account’s key was deleted and its scheduled
|
|
165
|
+
fetches stopped. Nothing here can bring it back —{' '}
|
|
166
|
+
{who} has to connect with {row.connector_name} again themselves.
|
|
167
|
+
</p>
|
|
168
|
+
) : null}
|
|
146
169
|
</div>
|
|
147
|
-
{canManage && disconnect ? (
|
|
170
|
+
{canManage && disconnect && !alreadyDisconnected ? (
|
|
148
171
|
<button
|
|
149
172
|
type="button"
|
|
150
173
|
className={cn.button}
|
|
@@ -155,7 +178,11 @@ function MemberConnectionCard({
|
|
|
155
178
|
</button>
|
|
156
179
|
) : null}
|
|
157
180
|
</div>
|
|
158
|
-
{
|
|
181
|
+
{/* `alreadyDisconnected` again, not because the button can open this today
|
|
182
|
+
but because a reload can flip the row to `disabled` under an open
|
|
183
|
+
prompt — and the one thing this prompt must never do is warn that an
|
|
184
|
+
action "cannot be undone" about a key that is already gone. */}
|
|
185
|
+
{confirming && !alreadyDisconnected ? (
|
|
159
186
|
<div className="mt-3 space-y-2">
|
|
160
187
|
{/* NAMES THE PERSON, because the cost of this button falls on them and
|
|
161
188
|
not on the admin pressing it. A generic "are you sure?" over a list
|
package/src/corpus/client.ts
CHANGED
|
@@ -221,6 +221,18 @@ function reportedFilters(query: CommitmentQuery, applied: string[]): string[] {
|
|
|
221
221
|
|
|
222
222
|
const EMPTY_PAGE = { count: 0, next: null, previous: null, results: [] };
|
|
223
223
|
|
|
224
|
+
/**
|
|
225
|
+
* One wire timestamp, or `null`.
|
|
226
|
+
*
|
|
227
|
+
* ABSENT AND EMPTY BOTH MEAN NULL, and nothing else is coerced: a freshness fact
|
|
228
|
+
* that arrives as `""` from a serializer that stringifies its nulls must not
|
|
229
|
+
* become the string `""` and then a `NaN` date that reads as 1970. Everything
|
|
230
|
+
* that is not a non-empty string is simply "we were not told".
|
|
231
|
+
*/
|
|
232
|
+
function at(value: unknown): string | null {
|
|
233
|
+
return typeof value === 'string' && value.trim() ? value : null;
|
|
234
|
+
}
|
|
235
|
+
|
|
224
236
|
/** Rows written in parallel. Small enough not to look like a burst to the tenant. */
|
|
225
237
|
const WRITE_CONCURRENCY = 6;
|
|
226
238
|
|
|
@@ -501,12 +513,25 @@ export function createCorpusApi(
|
|
|
501
513
|
const now = Date.now();
|
|
502
514
|
return (connectionPage.results ?? []).map((connection) => {
|
|
503
515
|
const key = String(connection.connector_key ?? connection.connectorKey ?? 'unknown');
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
516
|
+
// THREE FACTS, READ SEPARATELY (bd startsim-1cow). This was one `??`
|
|
517
|
+
// chain ending in `last_checked_at`, and central's own view says of that
|
|
518
|
+
// column that it "is a far more optimistic fact than either and is never
|
|
519
|
+
// used as a stand-in". Central sends `last_synced_at: null` for a
|
|
520
|
+
// connection that has never delivered, so the chain fell straight
|
|
521
|
+
// through and a connection whose CREDENTIAL was verified five minutes
|
|
522
|
+
// ago rendered as data that ARRIVED five minutes ago — over an empty
|
|
523
|
+
// corpus. Each fact is now read into its own variable and only
|
|
524
|
+
// `lastSyncedAt` may answer "is my data current".
|
|
525
|
+
//
|
|
526
|
+
// BOTH SPELLINGS, for the same reason `ServerFilterReport` reads both:
|
|
527
|
+
// `@startsimpli/api` camelCases response keys in the browser and a plain
|
|
528
|
+
// server-side fetch does not, and getting this wrong on the timestamps
|
|
529
|
+
// is invisible until somebody's Library quietly lies.
|
|
530
|
+
const lastSyncedAt = at(connection.last_synced_at ?? connection.lastSyncedAt);
|
|
531
|
+
const lastAttemptAt = at(connection.last_attempt_at ?? connection.lastAttemptAt);
|
|
532
|
+
const lastCheckedAt = at(connection.last_checked_at ?? connection.lastCheckedAt);
|
|
533
|
+
const failures =
|
|
534
|
+
Number(connection.consecutive_failures ?? connection.consecutiveFailures ?? 0) || 0;
|
|
510
535
|
const documentCount = Number(connection.document_count ?? connection.documentCount ?? 0);
|
|
511
536
|
const error = String(connection.check_error ?? connection.checkError ?? '');
|
|
512
537
|
const status = connection.status ?? '';
|
|
@@ -516,10 +541,27 @@ export function createCorpusApi(
|
|
|
516
541
|
if (error || status === 'error' || status === 'needs_reauth') {
|
|
517
542
|
health = 'error';
|
|
518
543
|
detail = error || `Connection status is ${String(status)}.`;
|
|
544
|
+
} else if (failures > 0 || (lastAttemptAt && !lastSyncedAt)) {
|
|
545
|
+
// IT TRIED AND IT FAILED. Central's view exists partly so that "a card
|
|
546
|
+
// cannot read 'nothing yet' while it is in fact failing on a
|
|
547
|
+
// schedule" — a run that FINISHED while nothing has ever finished well
|
|
548
|
+
// is a failed run, and a non-zero failure streak says so outright even
|
|
549
|
+
// when an older sync did land. Note what does NOT appear in this
|
|
550
|
+
// condition: `lastCheckedAt`. A verified key is not a delivered
|
|
551
|
+
// record, in either direction.
|
|
552
|
+
health = 'error';
|
|
553
|
+
detail =
|
|
554
|
+
failures > 0
|
|
555
|
+
? `${failures} sync${failures === 1 ? '' : 's'} in a row failed` +
|
|
556
|
+
`${lastSyncedAt ? '' : ' and nothing has arrived yet'}.`
|
|
557
|
+
: 'A sync ran but nothing arrived.';
|
|
519
558
|
} else if (!lastSyncedAt) {
|
|
559
|
+
// `never` regardless of `lastCheckedAt`, which is the whole bead.
|
|
520
560
|
health = 'never';
|
|
521
561
|
detail = 'Connected, but nothing has arrived yet.';
|
|
522
562
|
} else if (now - Date.parse(lastSyncedAt) > staleAfterHours * 3_600_000) {
|
|
563
|
+
// Measured against the SYNC. A credential re-checked this morning is
|
|
564
|
+
// exactly why a week-old corpus goes unnoticed.
|
|
523
565
|
health = 'stale';
|
|
524
566
|
detail = `Last sync was more than ${staleAfterHours} hours ago.`;
|
|
525
567
|
}
|
|
@@ -529,6 +571,9 @@ export function createCorpusApi(
|
|
|
529
571
|
label: String(connection.connector_name ?? connection.connectorName ?? sourceLabels[key] ?? key),
|
|
530
572
|
documentCount,
|
|
531
573
|
lastSyncedAt,
|
|
574
|
+
lastAttemptAt,
|
|
575
|
+
lastCheckedAt,
|
|
576
|
+
consecutiveFailures: failures,
|
|
532
577
|
status: health,
|
|
533
578
|
...(detail ? { detail } : {}),
|
|
534
579
|
};
|
package/src/corpus/types.ts
CHANGED
|
@@ -174,7 +174,31 @@ export interface SourceFreshness {
|
|
|
174
174
|
label: string;
|
|
175
175
|
/** How many documents this source has contributed to the corpus. */
|
|
176
176
|
documentCount: number;
|
|
177
|
+
/**
|
|
178
|
+
* THREE FACTS, AND CONFLATING ANY TWO OF THEM IS HOW A FRESHNESS STRIP LIES
|
|
179
|
+
* (bd startsim-1cow). Central's `SubtreeConnectionsView` computes them
|
|
180
|
+
* separately and says in its own source that the third "is never used as a
|
|
181
|
+
* stand-in"; this contract keeps them separate for the same reason.
|
|
182
|
+
*
|
|
183
|
+
* The last run that actually BROUGHT DATA IN. `null` means nothing ever has,
|
|
184
|
+
* and it stays `null` — a connection whose credential merely checked out is
|
|
185
|
+
* not a connection whose data arrived.
|
|
186
|
+
*/
|
|
177
187
|
lastSyncedAt: string | null;
|
|
188
|
+
/**
|
|
189
|
+
* The last run of ANY outcome. Present with a `null` `lastSyncedAt` means it
|
|
190
|
+
* TRIED AND FAILED, which is a different thing to say than "nothing yet" and
|
|
191
|
+
* needs a different action from the reader.
|
|
192
|
+
*/
|
|
193
|
+
lastAttemptAt?: string | null;
|
|
194
|
+
/**
|
|
195
|
+
* When the CREDENTIAL was last verified — far more optimistic than either of
|
|
196
|
+
* the above and never a substitute for them. Surfaced so a screen can say
|
|
197
|
+
* "the key is fine, the data is not", which is the useful sentence.
|
|
198
|
+
*/
|
|
199
|
+
lastCheckedAt?: string | null;
|
|
200
|
+
/** How many syncs in a row have failed. `0` (or absent) means none. */
|
|
201
|
+
consecutiveFailures?: number;
|
|
178
202
|
/**
|
|
179
203
|
* `ok` — synced recently and cleanly.
|
|
180
204
|
* `stale` — the last sync succeeded but was long enough ago to matter.
|