@flame0510/project-aether 1.1.15 → 1.2.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.
- package/app/api/credentials/[id]/sync/route.ts +3 -3
- package/app/api/credentials/detect/route.ts +126 -176
- package/app/api/credentials/route.ts +3 -0
- package/app/components/Skeleton.tsx +132 -0
- package/app/credentials/PageClient.tsx +460 -139
- package/app/credentials/loading.tsx +19 -5
- package/docs/ARCHITECTURE.md +78 -31
- package/docs/FRONTEND-ARCHITECTURE.md +13 -5
- package/docs/REV4A.md +23 -14
- package/docs/dev/API-REFERENCE.md +356 -184
- package/docs/dev/DATABASE.md +8 -3
- package/docs/dev/GATEWAY.md +9 -7
- package/docs/dev/PROVIDERS.md +22 -35
- package/docs/rag/GLOSSARY.md +8 -8
- package/docs/rag/REV4A-OVERVIEW.md +7 -10
- package/docs/rag/WHAT-I-CAN-ANSWER.md +6 -6
- package/lib/credentials/delivery.ts +212 -119
- package/lib/credentials/detect.ts +229 -97
- package/lib/credentials/providers.ts +38 -7
- package/lib/credentials/vault.ts +78 -13
- package/lib/docker-exec.ts +50 -14
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
|
-
import { getProfile, markSynced } from '@/lib/credentials/vault';
|
|
2
|
+
import { getProfile, markSynced, markDesynced } from '@/lib/credentials/vault';
|
|
3
3
|
import { syncProfileToAgents, cleanAgentProviders } from '@/lib/credentials/delivery';
|
|
4
4
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
5
5
|
|
|
@@ -48,8 +48,8 @@ export async function DELETE(request: Request,
|
|
|
48
48
|
return NextResponse.json({ error: 'container is required' }, { status: 400 });
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
cleanAgentProviders(container, profile.providerId);
|
|
52
|
-
|
|
51
|
+
await cleanAgentProviders(container, profile.providerId);
|
|
52
|
+
markDesynced(id);
|
|
53
53
|
|
|
54
54
|
return NextResponse.json({ ok: true });
|
|
55
55
|
} catch (e: unknown) {
|
|
@@ -2,17 +2,22 @@ import { NextResponse, type NextRequest } from 'next/server';
|
|
|
2
2
|
import { listProfiles, getSecret } from '@/lib/credentials/vault';
|
|
3
3
|
import {
|
|
4
4
|
listAgentContainers,
|
|
5
|
-
|
|
6
|
-
cliInstalled,
|
|
5
|
+
probeContainer,
|
|
7
6
|
providerTokenHash,
|
|
8
7
|
profileTokenHash,
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
vercelCurrentTeam,
|
|
9
|
+
supabaseProjectRef,
|
|
10
|
+
TOKEN_FIELD,
|
|
11
|
+
type ContainerProbe,
|
|
11
12
|
} from '@/lib/credentials/detect';
|
|
13
|
+
import { mapWithConcurrency } from '@/lib/docker-exec';
|
|
12
14
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
13
15
|
|
|
14
16
|
export const dynamic = 'force-dynamic';
|
|
15
17
|
|
|
18
|
+
/** Containers probed at once. Each is a single `docker exec`. */
|
|
19
|
+
const AGENT_QUERY_CONCURRENCY = 6;
|
|
20
|
+
|
|
16
21
|
interface DetectedStatus {
|
|
17
22
|
container: string;
|
|
18
23
|
agentId: string;
|
|
@@ -33,6 +38,12 @@ interface DetectedStatus {
|
|
|
33
38
|
notionLoggedIn: boolean;
|
|
34
39
|
toolsMdHasMarkers: boolean;
|
|
35
40
|
matchedProfiles: { profileId: string; providerId: string; label: string }[];
|
|
41
|
+
/**
|
|
42
|
+
* Providers with a token present in the container, whether or not it belongs
|
|
43
|
+
* to a stored profile. A provider listed here but absent from
|
|
44
|
+
* `matchedProfiles` holds a credential nobody in the vault accounts for.
|
|
45
|
+
*/
|
|
46
|
+
installedProviders: string[];
|
|
36
47
|
}
|
|
37
48
|
|
|
38
49
|
interface DetectionError {
|
|
@@ -41,125 +52,106 @@ interface DetectionError {
|
|
|
41
52
|
error: string;
|
|
42
53
|
}
|
|
43
54
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
// Verify login by calling the Supabase API (analogous to gh auth status / vercel whoami)
|
|
50
|
-
const raw = execInContainer(
|
|
51
|
-
container,
|
|
52
|
-
'supabase projects list --output json 2>&1',
|
|
53
|
-
(r) => r.trim(),
|
|
54
|
-
'',
|
|
55
|
-
);
|
|
56
|
-
|
|
57
|
-
// Unauthorized → not logged in
|
|
58
|
-
if (!raw || raw.includes('Unauthorized')) {
|
|
59
|
-
return { loggedIn: false, linked: false, projectRef: '' };
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Check if a linked project exists in the workspace
|
|
63
|
-
let linked = false;
|
|
64
|
-
let projectRef = '';
|
|
65
|
-
const linkedRef = execInContainer(
|
|
66
|
-
container,
|
|
67
|
-
'cat /data/supabase/config.toml 2>/dev/null | grep project_id | head -1 | sed "s/.*= *\"//;s/\".*//"',
|
|
68
|
-
(r) => r.trim(),
|
|
69
|
-
'',
|
|
70
|
-
);
|
|
71
|
-
if (linkedRef && linkedRef.length > 0) {
|
|
72
|
-
linked = true;
|
|
73
|
-
projectRef = linkedRef;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
return { loggedIn: true, linked, projectRef };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function detectGithub(container: string): { loggedIn: boolean; user: string } {
|
|
80
|
-
if (!cliInstalled(container, 'gh')) {
|
|
81
|
-
return { loggedIn: false, user: '' };
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const raw = execInContainer(
|
|
85
|
-
container,
|
|
86
|
-
'gh auth status 2>&1',
|
|
87
|
-
(r) => r,
|
|
88
|
-
'',
|
|
89
|
-
);
|
|
90
|
-
if (!raw || raw.includes('not logged into any GitHub hosts')) {
|
|
91
|
-
return { loggedIn: false, user: '' };
|
|
92
|
-
}
|
|
93
|
-
const match = raw.match(/Logged in to github\.com as (\S+)/);
|
|
94
|
-
return { loggedIn: !!match, user: match?.[1] ?? '' };
|
|
55
|
+
/** Last non-empty line of `vercel whoami` — the rest is header noise. */
|
|
56
|
+
function extractUsername(raw: string): string {
|
|
57
|
+
const lines = raw.split('\n').filter((l) => l.trim());
|
|
58
|
+
return lines[lines.length - 1]?.trim() || raw.trim();
|
|
95
59
|
}
|
|
96
60
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Stored profiles with their token hashes, computed once per request.
|
|
63
|
+
*
|
|
64
|
+
* This used to run inside the per-container loop: `listProfiles()` plus one
|
|
65
|
+
* `getSecret()` per profile, each opening its own SQLite connection, repeated
|
|
66
|
+
* for every agent. Nothing in it depends on the container, so it is hoisted.
|
|
67
|
+
*/
|
|
68
|
+
interface HashedProfile {
|
|
69
|
+
profileId: string;
|
|
70
|
+
providerId: string;
|
|
71
|
+
label: string;
|
|
72
|
+
tokenHash: string;
|
|
105
73
|
}
|
|
106
74
|
|
|
107
|
-
function
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
(
|
|
116
|
-
'',
|
|
117
|
-
);
|
|
118
|
-
if (whoami && !whoami.includes('Error:') && !whoami.includes('Not authorized')) {
|
|
119
|
-
const teamRaw = execInContainer(
|
|
120
|
-
container,
|
|
121
|
-
"cat /root/.local/share/com.vercel.cli/config.json 2>/dev/null | grep 'currentTeam' | head -1 | sed 's/.*: *\"//;s/\".*//'",
|
|
122
|
-
(r) => r.trim(),
|
|
123
|
-
'',
|
|
124
|
-
);
|
|
125
|
-
return { loggedIn: true, user: extractUsername(whoami), team: teamRaw };
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
return { loggedIn: false, user: '', team: '' };
|
|
75
|
+
function loadHashedProfiles(): HashedProfile[] {
|
|
76
|
+
return listProfiles()
|
|
77
|
+
.map((p) => ({
|
|
78
|
+
profileId: p.id,
|
|
79
|
+
providerId: p.providerId,
|
|
80
|
+
label: p.label,
|
|
81
|
+
tokenHash: profileTokenHash(getSecret(p.id), p.providerId),
|
|
82
|
+
}))
|
|
83
|
+
.filter((p) => p.tokenHash);
|
|
129
84
|
}
|
|
130
85
|
|
|
131
86
|
/**
|
|
132
|
-
*
|
|
87
|
+
* Hash of whatever token each provider currently has installed in the container.
|
|
88
|
+
*
|
|
89
|
+
* Computed for every provider, not only those with a stored profile. The probe
|
|
90
|
+
* has already fetched all of these files in its single exec, so the extra work
|
|
91
|
+
* is a handful of SHA-256 over short strings — and restricting it would hide the
|
|
92
|
+
* case that matters most: a token present in the container that matches nothing
|
|
93
|
+
* in the vault, which is what a credential edited after syncing looks like.
|
|
133
94
|
*/
|
|
134
|
-
function
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
95
|
+
function installedHashes(probe: ContainerProbe): Map<string, string> {
|
|
96
|
+
const out = new Map<string, string>();
|
|
97
|
+
for (const providerId of Object.keys(TOKEN_FIELD)) {
|
|
98
|
+
const h = providerTokenHash(probe, providerId);
|
|
99
|
+
if (h) out.set(providerId, h);
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
138
102
|
}
|
|
139
103
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
*/
|
|
146
|
-
function matchProfiles(
|
|
147
|
-
hashes: Record<string, string>,
|
|
148
|
-
): { profileId: string; providerId: string; label: string }[] {
|
|
149
|
-
const profiles = listProfiles();
|
|
150
|
-
const matched: { profileId: string; providerId: string; label: string }[] = [];
|
|
151
|
-
|
|
152
|
-
for (const profile of profiles) {
|
|
153
|
-
const installedHash = hashes[profile.providerId];
|
|
154
|
-
if (!installedHash) continue;
|
|
155
|
-
|
|
156
|
-
const storedHash = profileTokenHash(getSecret(profile.id), profile.providerId);
|
|
157
|
-
if (storedHash && storedHash === installedHash) {
|
|
158
|
-
matched.push({ profileId: profile.id, providerId: profile.providerId, label: profile.label });
|
|
159
|
-
}
|
|
160
|
-
}
|
|
104
|
+
function matchProfiles(installed: Map<string, string>, profiles: HashedProfile[]) {
|
|
105
|
+
return profiles
|
|
106
|
+
.filter((p) => installed.get(p.providerId) === p.tokenHash)
|
|
107
|
+
.map(({ profileId, providerId, label }) => ({ profileId, providerId, label }));
|
|
108
|
+
}
|
|
161
109
|
|
|
162
|
-
|
|
110
|
+
function shape(container: string, agentId: string, probe: ContainerProbe, profiles: HashedProfile[]): DetectedStatus {
|
|
111
|
+
const installed = installedHashes(probe);
|
|
112
|
+
// `gh` changed the wording: 2.97 prints "…github.com account <user>", older
|
|
113
|
+
// builds print "…as <user>". The image installs it unpinned from apt, so both
|
|
114
|
+
// are accepted rather than tracking whichever one ships today.
|
|
115
|
+
const ghMatch = /Logged in to github\.com (?:as|account) (\S+)/.exec(probe.ghStatus);
|
|
116
|
+
const githubLoggedIn =
|
|
117
|
+
probe.cli.gh && !probe.ghStatus.includes('not logged into any GitHub hosts') && !!ghMatch;
|
|
118
|
+
|
|
119
|
+
const whoami = probe.vercelWhoami.trim();
|
|
120
|
+
const vercelLoggedIn =
|
|
121
|
+
probe.cli.vercel && !!whoami && !whoami.includes('Error:') && !whoami.includes('Not authorized');
|
|
122
|
+
|
|
123
|
+
const projects = probe.supabaseProjects.trim();
|
|
124
|
+
// The exit status is the signal, not the wording. Matching on 'Unauthorized'
|
|
125
|
+
// recognised only the one failure whose text happened to say so; a missing
|
|
126
|
+
// token and an unreachable network both printed something else and passed.
|
|
127
|
+
const supabaseLoggedIn = probe.cli.supabase && probe.supabaseRc === 0 && !!projects;
|
|
128
|
+
const projectRef = supabaseLoggedIn ? supabaseProjectRef(probe) : '';
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
container,
|
|
132
|
+
agentId,
|
|
133
|
+
displayName: probe.agentName || undefined,
|
|
134
|
+
githubLoggedIn,
|
|
135
|
+
githubUser: ghMatch?.[1] ?? '',
|
|
136
|
+
githubCliInstalled: probe.cli.gh,
|
|
137
|
+
vercelLoggedIn,
|
|
138
|
+
vercelUser: vercelLoggedIn ? extractUsername(probe.vercelWhoami) : '',
|
|
139
|
+
vercelTeam: vercelLoggedIn ? vercelCurrentTeam(probe) : '',
|
|
140
|
+
vercelCliInstalled: probe.cli.vercel,
|
|
141
|
+
supabaseLoggedIn,
|
|
142
|
+
supabaseLinked: !!projectRef,
|
|
143
|
+
supabaseProjectRef: projectRef,
|
|
144
|
+
supabaseCliInstalled: probe.cli.supabase,
|
|
145
|
+
// Presence of the config file is the login signal for both — neither ships a
|
|
146
|
+
// status command. An empty file now counts as absent, where `test -f` used
|
|
147
|
+
// to accept it.
|
|
148
|
+
trelloLoggedIn: !!probe.files.trelloConfig.trim(),
|
|
149
|
+
trelloCliInstalled: probe.cli.trello,
|
|
150
|
+
notionLoggedIn: !!probe.files.notionConfig.trim(),
|
|
151
|
+
toolsMdHasMarkers: probe.toolsMdMarkers > 0,
|
|
152
|
+
matchedProfiles: matchProfiles(installed, profiles),
|
|
153
|
+
installedProviders: [...installed.keys()],
|
|
154
|
+
};
|
|
163
155
|
}
|
|
164
156
|
|
|
165
157
|
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
@@ -167,8 +159,8 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
167
159
|
try {
|
|
168
160
|
const url = new URL(request.url);
|
|
169
161
|
const filterAgent = url.searchParams.get('agent');
|
|
170
|
-
let containers = listAgentContainers();
|
|
171
162
|
|
|
163
|
+
let containers = await listAgentContainers();
|
|
172
164
|
if (filterAgent) {
|
|
173
165
|
containers = containers.filter((c) => c.name === filterAgent);
|
|
174
166
|
if (containers.length === 0) {
|
|
@@ -176,70 +168,28 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
176
168
|
}
|
|
177
169
|
}
|
|
178
170
|
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
(r) => r.trim(),
|
|
200
|
-
'',
|
|
201
|
-
) || undefined;
|
|
202
|
-
|
|
203
|
-
// Precise profile matching by token hash — one source of truth for all providers.
|
|
204
|
-
const matchedProfiles = matchProfiles({
|
|
205
|
-
'github-pat': providerTokenHash(name, 'github-pat'),
|
|
206
|
-
vercel: providerTokenHash(name, 'vercel'),
|
|
207
|
-
supabase: providerTokenHash(name, 'supabase'),
|
|
208
|
-
trello: providerTokenHash(name, 'trello'),
|
|
209
|
-
notion: providerTokenHash(name, 'notion'),
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
results.push({
|
|
213
|
-
container: name,
|
|
214
|
-
agentId,
|
|
215
|
-
displayName,
|
|
216
|
-
githubLoggedIn,
|
|
217
|
-
githubUser,
|
|
218
|
-
githubCliInstalled,
|
|
219
|
-
vercelLoggedIn,
|
|
220
|
-
vercelUser,
|
|
221
|
-
vercelTeam,
|
|
222
|
-
vercelCliInstalled,
|
|
223
|
-
supabaseLoggedIn,
|
|
224
|
-
supabaseLinked,
|
|
225
|
-
supabaseProjectRef,
|
|
226
|
-
supabaseCliInstalled,
|
|
227
|
-
trelloLoggedIn,
|
|
228
|
-
trelloCliInstalled,
|
|
229
|
-
notionLoggedIn,
|
|
230
|
-
toolsMdHasMarkers,
|
|
231
|
-
matchedProfiles,
|
|
232
|
-
});
|
|
233
|
-
} catch (e: unknown) {
|
|
234
|
-
results.push({
|
|
235
|
-
container: name,
|
|
236
|
-
agentId,
|
|
237
|
-
error: (e as Error).message,
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
}
|
|
171
|
+
const profiles = loadHashedProfiles();
|
|
172
|
+
|
|
173
|
+
// The callback swallows its own failures: mapWithConcurrency is fail-fast,
|
|
174
|
+
// so letting one unreachable container reject would discard the results
|
|
175
|
+
// already computed for every other agent.
|
|
176
|
+
const agents = await mapWithConcurrency<{ name: string; agentId: string }, DetectedStatus | DetectionError>(
|
|
177
|
+
containers,
|
|
178
|
+
AGENT_QUERY_CONCURRENCY,
|
|
179
|
+
async ({ name, agentId }) => {
|
|
180
|
+
try {
|
|
181
|
+
const probe = await probeContainer(name);
|
|
182
|
+
if (!probe.ok) {
|
|
183
|
+
return { container: name, agentId, error: 'Container did not respond' };
|
|
184
|
+
}
|
|
185
|
+
return shape(name, agentId, probe, profiles);
|
|
186
|
+
} catch (e: unknown) {
|
|
187
|
+
return { container: name, agentId, error: (e as Error).message };
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
);
|
|
241
191
|
|
|
242
|
-
return NextResponse.json({ agents
|
|
192
|
+
return NextResponse.json({ agents });
|
|
243
193
|
} catch (e: unknown) {
|
|
244
194
|
return NextResponse.json({ error: (e as Error).message }, { status: 500 });
|
|
245
195
|
}
|
|
@@ -7,6 +7,9 @@ import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
|
7
7
|
export const dynamic = 'force-dynamic';
|
|
8
8
|
|
|
9
9
|
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
10
|
+
// requireAuthJWT was imported for the POST below and never applied here,
|
|
11
|
+
// leaving this the only unauthenticated route in the credentials feature.
|
|
12
|
+
const denied = await requireAuthJWT(request); if (denied) return denied as any;
|
|
10
13
|
try {
|
|
11
14
|
const profiles = listProfiles();
|
|
12
15
|
return NextResponse.json({ profiles, providers: CREDENTIAL_PROVIDERS });
|
|
@@ -202,3 +202,135 @@ export function CardRowSkeleton({ count = 3, height = 56 }: { count?: number; he
|
|
|
202
202
|
</div>
|
|
203
203
|
);
|
|
204
204
|
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Credential provider cards.
|
|
208
|
+
*
|
|
209
|
+
* The credentials page renders one card per entry in CREDENTIAL_PROVIDERS,
|
|
210
|
+
* which is a static array of five — so the card count here is known, not
|
|
211
|
+
* guessed, and the grid will not reflow when the data lands.
|
|
212
|
+
*
|
|
213
|
+
* Each shape mirrors the real text it stands in for: the label and category
|
|
214
|
+
* widths track the actual provider names ("Cloud" is short, "Project
|
|
215
|
+
* Management" is not), and the description line count follows how long each
|
|
216
|
+
* provider's blurb actually is. That is what keeps the shimmer from jumping
|
|
217
|
+
* when it is replaced.
|
|
218
|
+
*
|
|
219
|
+
* `configuredCards` is the one genuine unknown: whether a provider has stored
|
|
220
|
+
* profiles is only known once /api/credentials answers. Cards with a profile
|
|
221
|
+
* are markedly taller, so rendering none would guarantee a jump downward for
|
|
222
|
+
* anyone who has set some up. Two of five is the middle of the realistic range
|
|
223
|
+
* and keeps the error small in both directions.
|
|
224
|
+
*/
|
|
225
|
+
export function CredentialCardsSkeleton({ configuredCards = 2 }: { configuredCards?: number }) {
|
|
226
|
+
// Ordered as CREDENTIAL_PROVIDERS: GitHub, Trello, Vercel, Supabase, Notion.
|
|
227
|
+
const cards = [
|
|
228
|
+
{ label: 46, cat: 84, pill: 64, desc: ['100%', '58%'], delivery: '56%' },
|
|
229
|
+
{ label: 44, cat: 98, pill: 54, desc: ['92%'], delivery: '82%' },
|
|
230
|
+
{ label: 44, cat: 34, pill: 64, desc: ['100%', '64%'], delivery: '90%' },
|
|
231
|
+
{ label: 58, cat: 50, pill: 54, desc: ['100%', '86%'], delivery: '78%' },
|
|
232
|
+
{ label: 44, cat: 98, pill: 54, desc: ['100%', '92%'], delivery: '72%' },
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<div
|
|
237
|
+
aria-hidden="true"
|
|
238
|
+
style={{
|
|
239
|
+
display: 'grid',
|
|
240
|
+
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
|
|
241
|
+
gap: 'clamp(12px, 3vw, 16px)',
|
|
242
|
+
}}
|
|
243
|
+
>
|
|
244
|
+
{cards.map((c, i) => (
|
|
245
|
+
<div
|
|
246
|
+
key={i}
|
|
247
|
+
style={{
|
|
248
|
+
background: 'var(--bg2)',
|
|
249
|
+
border: '1px solid var(--border)',
|
|
250
|
+
display: 'flex',
|
|
251
|
+
flexDirection: 'column',
|
|
252
|
+
}}
|
|
253
|
+
>
|
|
254
|
+
{/* Header: provider badge, name over category, status pill */}
|
|
255
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 14px', borderBottom: '1px solid var(--border)' }}>
|
|
256
|
+
<Skeleton width={28} height={28} accent style={{ flexShrink: 0 }} />
|
|
257
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
258
|
+
<Skeleton width={c.label} height={12} />
|
|
259
|
+
<Skeleton width={c.cat} height={9} style={{ marginTop: 4 }} />
|
|
260
|
+
</div>
|
|
261
|
+
<Skeleton width={c.pill} height={16} style={{ flexShrink: 0 }} />
|
|
262
|
+
</div>
|
|
263
|
+
|
|
264
|
+
{/* Description */}
|
|
265
|
+
<div style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)' }}>
|
|
266
|
+
{c.desc.map((w, j) => (
|
|
267
|
+
<Skeleton key={j} width={w} height={9} style={{ marginTop: j === 0 ? 0 : 5 }} />
|
|
268
|
+
))}
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
{/* Delivery method — one mono line */}
|
|
272
|
+
<div style={{ padding: '8px 14px', borderBottom: '1px solid var(--border)' }}>
|
|
273
|
+
<Skeleton width={c.delivery} height={9} />
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
{/* A stored profile: label, masked secret + status + sync time, actions,
|
|
277
|
+
then the per-agent sync rows underneath. */}
|
|
278
|
+
{i < configuredCards && (
|
|
279
|
+
<div style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)' }}>
|
|
280
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
281
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
282
|
+
<Skeleton width="52%" height={11} />
|
|
283
|
+
<div style={{ display: 'flex', gap: 10, marginTop: 5 }}>
|
|
284
|
+
<Skeleton width={76} height={9} />
|
|
285
|
+
<Skeleton width={8} height={9} />
|
|
286
|
+
<Skeleton width={62} height={9} />
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
<Skeleton width={38} height={22} style={{ flexShrink: 0 }} />
|
|
290
|
+
<Skeleton width={22} height={22} style={{ flexShrink: 0 }} />
|
|
291
|
+
</div>
|
|
292
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 5, marginTop: 9 }}>
|
|
293
|
+
<Skeleton width="74%" height={9} />
|
|
294
|
+
<Skeleton width="66%" height={9} />
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
)}
|
|
298
|
+
|
|
299
|
+
{/* Footer action, pinned to the bottom as in the real card */}
|
|
300
|
+
<div style={{ padding: '10px 14px', marginTop: 'auto' }}>
|
|
301
|
+
<Skeleton width={i < configuredCards ? 92 : 74} height={26} />
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
))}
|
|
305
|
+
</div>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Per-agent sync rows inside a credential profile.
|
|
311
|
+
*
|
|
312
|
+
* Stands in for the block fed by /api/credentials/detect, which shells into
|
|
313
|
+
* every agent container and takes seconds. The agent list itself comes from that
|
|
314
|
+
* same response, so before it lands the count is unknown — three rows is a
|
|
315
|
+
* placeholder, not a prediction, and the block is deliberately the only thing
|
|
316
|
+
* that waits: the cards, the stored profiles and the Add buttons all come from
|
|
317
|
+
* the cheap endpoint and render immediately.
|
|
318
|
+
*
|
|
319
|
+
* No Sync/De-sync button is drawn here on purpose. Their label depends on
|
|
320
|
+
* whether the credential is installed, which is exactly what is still unknown —
|
|
321
|
+
* a button rendered now would have to guess, and guessing wrong invites a click
|
|
322
|
+
* that does the opposite of what it says.
|
|
323
|
+
*/
|
|
324
|
+
export function AgentSyncRowsSkeleton({ rows = 3 }: { rows?: number }) {
|
|
325
|
+
const widths = ['62%', '48%', '55%'];
|
|
326
|
+
return (
|
|
327
|
+
<div aria-hidden="true" style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
328
|
+
{Array.from({ length: rows }).map((_, i) => (
|
|
329
|
+
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
330
|
+
<Skeleton width={8} height={8} variant="circle" style={{ flexShrink: 0 }} />
|
|
331
|
+
<Skeleton width={widths[i % widths.length]} height={9} />
|
|
332
|
+
</div>
|
|
333
|
+
))}
|
|
334
|
+
</div>
|
|
335
|
+
);
|
|
336
|
+
}
|