mirror of
https://github.com/graphql-hive/console
synced 2026-04-21 14:37:17 +00:00
883 lines
29 KiB
TypeScript
883 lines
29 KiB
TypeScript
/**
|
||
* Seeds a complete Insights development environment from scratch.
|
||
*
|
||
* Creates: owner account, org, project, target, schema, usage data (30 days),
|
||
* and saved filters with view counts.
|
||
*
|
||
* Prerequisites:
|
||
* - Docker Compose is running (pnpm local:setup)
|
||
* - Services are running (pnpm dev:hive)
|
||
*
|
||
* Usage:
|
||
* bun scripts/seed-insights.mts
|
||
*/
|
||
|
||
import * as readline from 'node:readline/promises';
|
||
import setCookie from 'set-cookie-parser';
|
||
import type { CollectedOperation } from '../integration-tests/testkit/usage';
|
||
|
||
process.env.RUN_AGAINST_LOCAL_SERVICES = '1';
|
||
await import('../integration-tests/local-dev.ts');
|
||
|
||
const { ensureEnv } = await import('../integration-tests/testkit/env');
|
||
const { createOrganization, createProject, createToken, publishSchema } = await import(
|
||
'../integration-tests/testkit/flow'
|
||
);
|
||
const { execute } = await import('../integration-tests/testkit/graphql');
|
||
const { legacyCollect } = await import('../integration-tests/testkit/usage');
|
||
const { generateUnique, getServiceHost } = await import('../integration-tests/testkit/utils');
|
||
const { TargetAccessScope, ProjectType, SavedFilterVisibilityType } = await import(
|
||
'../integration-tests/testkit/gql/graphql'
|
||
);
|
||
const { CreateSavedFilterMutation, TrackSavedFilterViewMutation } = await import(
|
||
'../integration-tests/testkit/saved-filters'
|
||
);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Auth helper — handles both new and existing SuperTokens users
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const password = 'ilikebigturtlesandicannotlie47';
|
||
|
||
async function signInOrSignUp(
|
||
email: string,
|
||
): Promise<{ access_token: string; refresh_token: string }> {
|
||
const graphqlAddress = await getServiceHost('server', 8082);
|
||
|
||
let response = await fetch(`http://${graphqlAddress}/auth-api/signup`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
formFields: [
|
||
{
|
||
id: 'email',
|
||
value: email,
|
||
},
|
||
{
|
||
id: 'password',
|
||
value: password,
|
||
},
|
||
],
|
||
}),
|
||
headers: {
|
||
'content-type': 'application/json',
|
||
},
|
||
});
|
||
|
||
let body = await response.json();
|
||
if (body.status === 'OK') {
|
||
const cookies = setCookie.parse(response.headers.getSetCookie());
|
||
return {
|
||
access_token: cookies.find(c => c.name === 'sAccessToken')?.value ?? '',
|
||
refresh_token: cookies.find(c => c.name === 'sRefreshToken')?.value ?? '',
|
||
};
|
||
}
|
||
|
||
console.log('signup response', JSON.stringify(body, null, 2));
|
||
console.log('attempt sign in');
|
||
|
||
response = await fetch(`http://${graphqlAddress}/auth-api/signin`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
formFields: [
|
||
{
|
||
id: 'email',
|
||
value: email,
|
||
},
|
||
{
|
||
id: 'password',
|
||
value: password,
|
||
},
|
||
],
|
||
}),
|
||
headers: {
|
||
'content-type': 'application/json',
|
||
},
|
||
});
|
||
|
||
body = await response.json();
|
||
|
||
if (body.status === 'OK') {
|
||
const cookies = setCookie.parse(response.headers.getSetCookie());
|
||
return {
|
||
access_token: cookies.find(c => c.name === 'sAccessToken')?.value ?? '',
|
||
refresh_token: cookies.find(c => c.name === 'sRefreshToken')?.value ?? '',
|
||
};
|
||
}
|
||
|
||
throw new Error('Failed to sign in or up ' + JSON.stringify(body, null, 2));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 1. Operations — ~1000 distinct queries/mutations against the Star Wars schema
|
||
// ---------------------------------------------------------------------------
|
||
|
||
type OperationDef = {
|
||
operation: string;
|
||
operationName: string;
|
||
fields: string[];
|
||
};
|
||
|
||
const EPISODES = ['NEWHOPE', 'EMPIRE', 'JEDI'] as const;
|
||
|
||
// Field selection templates for Character
|
||
const CHARACTER_SELECTIONS = [
|
||
{ body: 'name', fields: ['Character', 'Character.name'] },
|
||
{ body: 'name appearsIn', fields: ['Character', 'Character.name', 'Character.appearsIn'] },
|
||
{ body: 'name friends { name }', fields: ['Character', 'Character.name', 'Character.friends'] },
|
||
{
|
||
body: 'name appearsIn friends { name }',
|
||
fields: ['Character', 'Character.name', 'Character.appearsIn', 'Character.friends'],
|
||
},
|
||
{
|
||
body: 'name friends { name appearsIn }',
|
||
fields: ['Character', 'Character.name', 'Character.friends', 'Character.appearsIn'],
|
||
},
|
||
{
|
||
body: 'name friends { name friends { name } }',
|
||
fields: ['Character', 'Character.name', 'Character.friends'],
|
||
},
|
||
];
|
||
|
||
// Inline fragment templates
|
||
const HUMAN_SELECTIONS = [
|
||
{
|
||
body: '... on Human { name starships { name } }',
|
||
fields: ['Human', 'Human.name', 'Human.starships', 'Starship', 'Starship.name'],
|
||
},
|
||
{
|
||
body: '... on Human { name totalCredits }',
|
||
fields: ['Human', 'Human.name', 'Human.totalCredits'],
|
||
},
|
||
{
|
||
body: '... on Human { name starships { name length } }',
|
||
fields: [
|
||
'Human',
|
||
'Human.name',
|
||
'Human.starships',
|
||
'Starship',
|
||
'Starship.name',
|
||
'Starship.length',
|
||
],
|
||
},
|
||
{
|
||
body: '... on Human { name totalCredits starships { name } }',
|
||
fields: [
|
||
'Human',
|
||
'Human.name',
|
||
'Human.totalCredits',
|
||
'Human.starships',
|
||
'Starship',
|
||
'Starship.name',
|
||
],
|
||
},
|
||
];
|
||
|
||
const DROID_SELECTIONS = [
|
||
{
|
||
body: '... on Droid { name primaryFunction }',
|
||
fields: ['Droid', 'Droid.name', 'Droid.primaryFunction'],
|
||
},
|
||
{
|
||
body: '... on Droid { name }',
|
||
fields: ['Droid', 'Droid.name'],
|
||
},
|
||
];
|
||
|
||
const REVIEW_SELECTIONS = [
|
||
{ body: 'stars', fields: ['Review', 'Review.stars'] },
|
||
{ body: 'stars commentary', fields: ['Review', 'Review.stars', 'Review.commentary'] },
|
||
{ body: 'episode stars', fields: ['Review', 'Review.episode', 'Review.stars'] },
|
||
{
|
||
body: 'episode stars commentary',
|
||
fields: ['Review', 'Review.episode', 'Review.stars', 'Review.commentary'],
|
||
},
|
||
];
|
||
|
||
function generateOperations(): OperationDef[] {
|
||
const ops: OperationDef[] = [];
|
||
let idx = 0;
|
||
|
||
// 1. Simple hero queries per episode x character selection (3 × 6 = 18)
|
||
for (const ep of EPISODES) {
|
||
for (const sel of CHARACTER_SELECTIONS) {
|
||
const name = `GetHero_${idx++}`;
|
||
ops.push({
|
||
operation: `query ${name} { hero(episode: ${ep}) { ${sel.body} } }`,
|
||
operationName: name,
|
||
fields: ['Query', 'Query.hero', ...sel.fields],
|
||
});
|
||
}
|
||
}
|
||
|
||
// 2. Human fragment queries per episode (3 × 4 = 12)
|
||
for (const ep of EPISODES) {
|
||
for (const sel of HUMAN_SELECTIONS) {
|
||
const name = `GetHuman_${idx++}`;
|
||
ops.push({
|
||
operation: `query ${name} { hero(episode: ${ep}) { ${sel.body} } }`,
|
||
operationName: name,
|
||
fields: ['Query', 'Query.hero', ...sel.fields],
|
||
});
|
||
}
|
||
}
|
||
|
||
// 3. Droid fragment queries per episode (3 × 2 = 6)
|
||
for (const ep of EPISODES) {
|
||
for (const sel of DROID_SELECTIONS) {
|
||
const name = `GetDroid_${idx++}`;
|
||
ops.push({
|
||
operation: `query ${name} { hero(episode: ${ep}) { ${sel.body} } }`,
|
||
operationName: name,
|
||
fields: ['Query', 'Query.hero', ...sel.fields],
|
||
});
|
||
}
|
||
}
|
||
|
||
// 4. Combined Human + Droid queries per episode (3 × 4 × 2 = 24)
|
||
for (const ep of EPISODES) {
|
||
for (const hSel of HUMAN_SELECTIONS) {
|
||
for (const dSel of DROID_SELECTIONS) {
|
||
const name = `GetCharacterDetails_${idx++}`;
|
||
ops.push({
|
||
operation: `query ${name} { hero(episode: ${ep}) { name ${hSel.body} ${dSel.body} } }`,
|
||
operationName: name,
|
||
fields: [
|
||
'Query',
|
||
'Query.hero',
|
||
'Character',
|
||
'Character.name',
|
||
...hSel.fields,
|
||
...dSel.fields,
|
||
],
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// 5. Multi-alias queries — different episode combos (3 choose 2 = 3, with varying selections = ~18)
|
||
const epPairs: [string, string][] = [
|
||
['NEWHOPE', 'EMPIRE'],
|
||
['NEWHOPE', 'JEDI'],
|
||
['EMPIRE', 'JEDI'],
|
||
];
|
||
for (const [ep1, ep2] of epPairs) {
|
||
for (const sel of CHARACTER_SELECTIONS) {
|
||
const name = `Compare_${idx++}`;
|
||
ops.push({
|
||
operation: `query ${name} { a: hero(episode: ${ep1}) { ${sel.body} } b: hero(episode: ${ep2}) { ${sel.body} } }`,
|
||
operationName: name,
|
||
fields: ['Query', 'Query.hero', ...sel.fields],
|
||
});
|
||
}
|
||
}
|
||
|
||
// 6. Triple-alias queries (1 × 6 = 6)
|
||
for (const sel of CHARACTER_SELECTIONS) {
|
||
const name = `AllEpisodeHeroes_${idx++}`;
|
||
ops.push({
|
||
operation: `query ${name} { newhope: hero(episode: NEWHOPE) { ${sel.body} } empire: hero(episode: EMPIRE) { ${sel.body} } jedi: hero(episode: JEDI) { ${sel.body} } }`,
|
||
operationName: name,
|
||
fields: ['Query', 'Query.hero', ...sel.fields],
|
||
});
|
||
}
|
||
|
||
// 7. Mutation variations — createReview per episode x review selection (3 × 4 = 12)
|
||
const STARS = [1, 2, 3, 4, 5];
|
||
for (const ep of EPISODES) {
|
||
for (const sel of REVIEW_SELECTIONS) {
|
||
const name = `CreateReview_${idx++}`;
|
||
const stars = STARS[idx % STARS.length];
|
||
ops.push({
|
||
operation: `mutation ${name} { createReview(episode: ${ep}, review: { stars: ${stars} }) { ${sel.body} } }`,
|
||
operationName: name,
|
||
fields: ['Mutation', 'Mutation.createReview', ...sel.fields],
|
||
});
|
||
}
|
||
}
|
||
|
||
// 8. Generate more unique queries to reach ~1000 by varying naming patterns
|
||
// Simulate realistic operation names like a real codebase would have
|
||
// Mix of short and long prefixes to test truncation at varying widths
|
||
const PREFIXES = [
|
||
'Dashboard',
|
||
'Settings',
|
||
'Profile',
|
||
'Admin',
|
||
'Search',
|
||
'Feed',
|
||
'Sync',
|
||
'OrganizationBillingSubscriptionDetails',
|
||
'TargetSchemaVersionComparison',
|
||
'ProjectAccessTokenPermissionsManagement',
|
||
'UserNotificationPreferencesUpdate',
|
||
'SchemaRegistryExplorerTypeDetails',
|
||
'IntegrationWebhookDeliveryStatus',
|
||
'AlertChannelConfigurationValidation',
|
||
'PersistedOperationCollectionSync',
|
||
'GraphQLEndpointLatencyPercentiles',
|
||
'CDNAccessTokenRotation',
|
||
'SchemaContractCompositionValidation',
|
||
'MemberRoleAssignmentAuditLog',
|
||
'OperationBodyNormalizationPreview',
|
||
];
|
||
const SUFFIXES = [
|
||
'Query',
|
||
'Fetch',
|
||
'Load',
|
||
'Get',
|
||
'List',
|
||
'Detail',
|
||
'Summary',
|
||
'Overview',
|
||
'Stats',
|
||
'Count',
|
||
'WithPaginationAndFilters',
|
||
'ByOrganizationSlug',
|
||
'ForDateRangeComparison',
|
||
];
|
||
|
||
while (ops.length < 1000) {
|
||
const prefix = PREFIXES[idx % PREFIXES.length];
|
||
const suffix = SUFFIXES[Math.floor(idx / PREFIXES.length) % SUFFIXES.length];
|
||
const ep = EPISODES[idx % 3];
|
||
const charSel = CHARACTER_SELECTIONS[idx % CHARACTER_SELECTIONS.length];
|
||
const name = `${prefix}${suffix}_${idx++}`;
|
||
ops.push({
|
||
operation: `query ${name} { hero(episode: ${ep}) { ${charSel.body} } }`,
|
||
operationName: name,
|
||
fields: ['Query', 'Query.hero', ...charSel.fields],
|
||
});
|
||
}
|
||
|
||
return ops;
|
||
}
|
||
|
||
const OPERATIONS = generateOperations();
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 2. Clients — 7 clients with 0–30 versions each
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface ClientDef {
|
||
name: string;
|
||
versions: string[];
|
||
weight: number; // relative traffic weight
|
||
}
|
||
|
||
function generateVersions(prefix: string, count: number): string[] {
|
||
return Array.from({ length: count }, (_, i) => `${prefix}.${i}.0`);
|
||
}
|
||
|
||
// Mix of short and long client names to test truncation at varying widths
|
||
const CLIENTS: ClientDef[] = [
|
||
{ name: 'web-app', versions: generateVersions('1', 15), weight: 30 },
|
||
{ name: 'ios', versions: generateVersions('2', 25), weight: 25 },
|
||
{ name: 'android', versions: generateVersions('3', 10), weight: 15 },
|
||
{ name: 'graphql-playground', versions: [], weight: 5 },
|
||
{ name: 'admin-dashboard-internal-tools', versions: generateVersions('1', 5), weight: 8 },
|
||
{
|
||
name: 'mobile-backend-for-frontend-service',
|
||
versions: generateVersions('0', 30),
|
||
weight: 12,
|
||
},
|
||
{ name: 'analytics-pipeline-worker-v2', versions: generateVersions('1', 8), weight: 5 },
|
||
];
|
||
|
||
const TOTAL_WEIGHT = CLIENTS.reduce((s, c) => s + c.weight, 0);
|
||
|
||
function pickWeightedClient(): { name: string; version: string | undefined } {
|
||
let r = Math.random() * TOTAL_WEIGHT;
|
||
for (const client of CLIENTS) {
|
||
r -= client.weight;
|
||
if (r <= 0) {
|
||
const version =
|
||
client.versions.length > 0
|
||
? client.versions[Math.floor(Math.random() * client.versions.length)]
|
||
: undefined;
|
||
return { name: client.name, version };
|
||
}
|
||
}
|
||
// fallback
|
||
return { name: CLIENTS[0].name, version: CLIENTS[0].versions[0] };
|
||
}
|
||
|
||
function randomDuration(): number {
|
||
// 50ms to 2s in nanoseconds, with most clustering around 100-500ms
|
||
const base = 50 + Math.random() * 450; // 50-500ms
|
||
const spike = Math.random() < 0.1 ? Math.random() * 1500 : 0; // 10% chance of slow
|
||
return Math.round((base + spike) * 1_000_000); // convert ms → ns
|
||
}
|
||
|
||
function randomOperation() {
|
||
return OPERATIONS[Math.floor(Math.random() * OPERATIONS.length)];
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 3. Schema SDL (Star Wars mono — matches scripts/seed-schemas/mono.graphql)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const SCHEMA_SDL = `
|
||
interface Node { id: ID! }
|
||
interface Character implements Node { id: ID! name: String! friends: [Character] appearsIn: [Episode]! }
|
||
type Human implements Character & Node { id: ID! name: String! friends: [Character] appearsIn: [Episode]! starships: [Starship] totalCredits: Int }
|
||
type Droid implements Character & Node { id: ID! name: String! friends: [Character] appearsIn: [Episode]! primaryFunction: String }
|
||
type Starship { id: ID! name: String! length(unit: LengthUnit = METER): Float }
|
||
enum LengthUnit { METER LIGHT_YEAR }
|
||
enum Episode { NEWHOPE EMPIRE JEDI }
|
||
type Query { hero(episode: Episode): Character }
|
||
type Review { episode: Episode stars: Int! commentary: String }
|
||
input ReviewInput { stars: Int! commentary: String }
|
||
type Mutation { createReview(episode: Episode, review: ReviewInput!): Review }
|
||
`.trim();
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 4. Prompt + Main
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const BATCH_SIZE = 500;
|
||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||
|
||
async function promptForEmail(): Promise<string> {
|
||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||
try {
|
||
const email = await rl.question('Enter owner email (or press Enter to auto-generate): ');
|
||
return email.trim();
|
||
} finally {
|
||
rl.close();
|
||
}
|
||
}
|
||
|
||
async function main() {
|
||
const inputEmail = await promptForEmail();
|
||
const ownerEmail = inputEmail || `${generateUnique()}-${Date.now()}@localhost.localhost`;
|
||
|
||
console.log(`\n🚀 Creating owner (${ownerEmail}), org, project...`);
|
||
const auth = await signInOrSignUp(ownerEmail);
|
||
const ownerToken = auth.access_token;
|
||
|
||
// Create organization
|
||
const orgSlug = generateUnique();
|
||
const orgResult = await createOrganization({ slug: orgSlug }, ownerToken).then(r =>
|
||
r.expectNoGraphQLErrors(),
|
||
);
|
||
const organization = orgResult.createOrganization.ok!.createdOrganizationPayload.organization;
|
||
|
||
// Create project
|
||
const projectResult = await createProject(
|
||
{
|
||
organization: { bySelector: { organizationSlug: organization.slug } },
|
||
type: ProjectType.Single,
|
||
slug: generateUnique(),
|
||
},
|
||
ownerToken,
|
||
).then(r => r.expectNoGraphQLErrors());
|
||
|
||
const project = projectResult.createProject.ok!.createdProject;
|
||
const target = projectResult.createProject.ok!.createdTargets[0];
|
||
|
||
console.log(` Org: ${organization.slug}`);
|
||
console.log(` Project: ${project.slug}`);
|
||
console.log(` Target: ${target.slug}`);
|
||
|
||
// Create access token
|
||
console.log('📝 Publishing schema...');
|
||
const tokenResult = await createToken(
|
||
{
|
||
name: generateUnique(),
|
||
organizationSlug: organization.slug,
|
||
projectSlug: project.slug,
|
||
targetSlug: target.slug,
|
||
organizationScopes: [],
|
||
projectScopes: [],
|
||
targetScopes: [TargetAccessScope.RegistryRead, TargetAccessScope.RegistryWrite],
|
||
},
|
||
ownerToken,
|
||
).then(r => r.expectNoGraphQLErrors());
|
||
|
||
const secret = tokenResult.createToken.ok!.secret;
|
||
|
||
const publishResult = await publishSchema(
|
||
{
|
||
author: 'seed-insights',
|
||
commit: 'seed',
|
||
sdl: SCHEMA_SDL,
|
||
force: true,
|
||
},
|
||
secret,
|
||
'authorization',
|
||
);
|
||
if (publishResult.rawBody.errors?.length) {
|
||
console.error('Schema publish failed:', publishResult.rawBody.errors);
|
||
process.exit(1);
|
||
}
|
||
console.log(' Schema published successfully.');
|
||
|
||
// Generate operations spread across 30 days
|
||
console.log('📊 Generating usage data (30 days)...');
|
||
const now = Date.now();
|
||
const allOperations: CollectedOperation[] = [];
|
||
|
||
for (let t = now - THIRTY_DAYS_MS; t <= now; t += ONE_HOUR_MS) {
|
||
const opsThisHour = 3 + Math.floor(Math.random() * 6); // 3–8 per hour
|
||
for (let i = 0; i < opsThisHour; i++) {
|
||
const op = randomOperation();
|
||
const client = pickWeightedClient();
|
||
const ok = Math.random() > 0.05;
|
||
allOperations.push({
|
||
timestamp: t + Math.floor(Math.random() * ONE_HOUR_MS),
|
||
operation: op.operation,
|
||
operationName: op.operationName,
|
||
fields: op.fields,
|
||
execution: {
|
||
ok,
|
||
duration: randomDuration(),
|
||
errorsTotal: ok ? 0 : 1,
|
||
},
|
||
metadata: {
|
||
client: {
|
||
name: client.name,
|
||
...(client.version ? { version: client.version } : {}),
|
||
},
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
console.log(` Generated ${allOperations.length} operations across 30 days.`);
|
||
|
||
// Send in batches
|
||
const totalBatches = Math.ceil(allOperations.length / BATCH_SIZE);
|
||
for (let i = 0; i < allOperations.length; i += BATCH_SIZE) {
|
||
const batch = allOperations.slice(i, i + BATCH_SIZE);
|
||
const batchNum = Math.floor(i / BATCH_SIZE) + 1;
|
||
process.stdout.write(` Sending batch ${batchNum}/${totalBatches}...`);
|
||
const result = await legacyCollect({
|
||
operations: batch,
|
||
token: secret,
|
||
authorizationHeader: 'authorization',
|
||
});
|
||
if (result.status !== 200) {
|
||
console.error(` FAILED (status ${result.status}):`, result.body);
|
||
} else {
|
||
const body = result.body as { operations: { accepted: number; rejected: number } };
|
||
console.log(` ✓ ${body.operations.accepted} accepted, ${body.operations.rejected} rejected`);
|
||
}
|
||
}
|
||
|
||
// Wait for ingestion
|
||
console.log('⏳ Waiting for usage ingestion (15s)...');
|
||
await new Promise(resolve => setTimeout(resolve, 15_000));
|
||
|
||
// Helper for saved filter operations
|
||
const targetSelector = {
|
||
organizationSlug: organization.slug,
|
||
projectSlug: project.slug,
|
||
targetSlug: target.slug,
|
||
};
|
||
|
||
// Fetch actual operation hashes from ingested data
|
||
console.log('🔍 Fetching operation hashes...');
|
||
const { parse } = await import('graphql');
|
||
const opsResult = await execute({
|
||
document: parse(/* GraphQL */ `
|
||
query SeedGetOperationHashes($target: TargetReferenceInput!, $period: DateRangeInput!) {
|
||
target(reference: $target) {
|
||
operationsStats(period: $period) {
|
||
operations {
|
||
edges {
|
||
node {
|
||
name
|
||
operationHash
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
`) as any,
|
||
variables: {
|
||
target: { bySelector: targetSelector },
|
||
period: {
|
||
from: new Date(now - THIRTY_DAYS_MS).toISOString(),
|
||
to: new Date(now).toISOString(),
|
||
},
|
||
},
|
||
authToken: ownerToken,
|
||
}).then(r => r.expectNoGraphQLErrors());
|
||
|
||
type OpsResult = {
|
||
target?: {
|
||
operationsStats?: {
|
||
operations?: {
|
||
edges: Array<{ node: { name: string; operationHash: string | null } }>;
|
||
};
|
||
};
|
||
};
|
||
};
|
||
const operationEdges = (opsResult as OpsResult).target?.operationsStats?.operations?.edges ?? [];
|
||
const operationHashMap = new Map<string, string>();
|
||
for (const edge of operationEdges) {
|
||
if (edge.node.operationHash && edge.node.name) {
|
||
// API returns names as "{hashPrefix}_{operationName}", extract the operationName part
|
||
const underscoreIdx = edge.node.name.indexOf('_');
|
||
const operationName =
|
||
underscoreIdx >= 0 ? edge.node.name.slice(underscoreIdx + 1) : edge.node.name;
|
||
operationHashMap.set(operationName, edge.node.operationHash);
|
||
}
|
||
}
|
||
console.log(
|
||
` Found ${operationHashMap.size} operations: ${[...operationHashMap.keys()].join(', ')}`,
|
||
);
|
||
|
||
async function createSavedFilter(input: {
|
||
name: string;
|
||
description?: string;
|
||
visibility: (typeof SavedFilterVisibilityType)[keyof typeof SavedFilterVisibilityType];
|
||
insightsFilter?: Record<string, unknown>;
|
||
}) {
|
||
const result = await execute({
|
||
document: CreateSavedFilterMutation,
|
||
variables: {
|
||
input: {
|
||
target: { bySelector: targetSelector },
|
||
name: input.name,
|
||
description: input.description,
|
||
visibility: input.visibility,
|
||
insightsFilter: input.insightsFilter,
|
||
},
|
||
},
|
||
authToken: ownerToken,
|
||
}).then(r => r.expectNoGraphQLErrors());
|
||
|
||
return result.createSavedFilter;
|
||
}
|
||
|
||
async function trackSavedFilterView(filterId: string) {
|
||
await execute({
|
||
document: TrackSavedFilterViewMutation,
|
||
variables: {
|
||
input: {
|
||
target: { bySelector: targetSelector },
|
||
id: filterId,
|
||
},
|
||
},
|
||
authToken: ownerToken,
|
||
}).then(r => r.expectNoGraphQLErrors());
|
||
}
|
||
|
||
// Create saved filters using real operation hashes
|
||
console.log('💾 Creating saved filters...');
|
||
|
||
// Helper to resolve operation names to hashes
|
||
function resolveOps(names: string[]): string[] {
|
||
return names.map(name => operationHashMap.get(name)).filter((h): h is string => h != null);
|
||
}
|
||
|
||
// Collect all known operation names for building saved filters
|
||
const allOpNames = [...operationHashMap.keys()];
|
||
console.log(` Using ${allOpNames.length} resolved operations for saved filters`);
|
||
|
||
// Define all saved filters (using slices of the resolved operation names)
|
||
const savedFilterDefs: Array<{
|
||
name: string;
|
||
description?: string;
|
||
visibility: (typeof SavedFilterVisibilityType)[keyof typeof SavedFilterVisibilityType];
|
||
operationNames: string[];
|
||
clientFilters?: Array<{ name: string; versions?: string[] }>;
|
||
dateRange?: { from: string; to: string };
|
||
views: number;
|
||
}> = [
|
||
{
|
||
name: 'High Traffic Operations',
|
||
description: 'Most frequently called queries',
|
||
visibility: SavedFilterVisibilityType.Shared,
|
||
operationNames: allOpNames.slice(0, 20),
|
||
clientFilters: [{ name: 'web-app' }, { name: 'ios' }],
|
||
dateRange: { from: 'now-7d', to: 'now' },
|
||
views: 372,
|
||
},
|
||
{
|
||
name: 'Mobile Clients',
|
||
description: 'iOS and Android traffic',
|
||
visibility: SavedFilterVisibilityType.Shared,
|
||
operationNames: allOpNames.slice(10, 25),
|
||
clientFilters: [
|
||
{ name: 'ios', versions: ['2.0.0', '2.5.0', '2.10.0', '2.20.0'] },
|
||
{ name: 'android', versions: ['3.0.0', '3.5.0', '3.9.0'] },
|
||
],
|
||
dateRange: { from: 'now-30d', to: 'now' },
|
||
views: 156,
|
||
},
|
||
{
|
||
name: 'My Debug View',
|
||
description: 'Debugging slow mutations',
|
||
visibility: SavedFilterVisibilityType.Private,
|
||
operationNames: allOpNames.slice(30, 35),
|
||
clientFilters: [{ name: 'graphql-playground' }],
|
||
dateRange: { from: 'now-1d', to: 'now' },
|
||
views: 23,
|
||
},
|
||
{
|
||
name: 'Web App — All Queries',
|
||
description: 'All query operations from the web app',
|
||
visibility: SavedFilterVisibilityType.Shared,
|
||
operationNames: allOpNames.slice(0, 50),
|
||
clientFilters: [{ name: 'web-app' }],
|
||
dateRange: { from: 'now-7d', to: 'now' },
|
||
views: 241,
|
||
},
|
||
{
|
||
name: 'Mutations Only',
|
||
description: 'All mutation operations across all clients',
|
||
visibility: SavedFilterVisibilityType.Shared,
|
||
operationNames: allOpNames.filter(n => n.startsWith('CreateReview')),
|
||
dateRange: { from: 'now-14d', to: 'now' },
|
||
views: 89,
|
||
},
|
||
{
|
||
name: 'Admin Dashboard',
|
||
description: 'Operations from the admin dashboard client',
|
||
visibility: SavedFilterVisibilityType.Shared,
|
||
operationNames: allOpNames.filter(n => n.startsWith('Dashboard')),
|
||
clientFilters: [{ name: 'admin-dashboard-internal-tools' }],
|
||
dateRange: { from: 'now-7d', to: 'now' },
|
||
views: 64,
|
||
},
|
||
{
|
||
name: 'BFF Layer',
|
||
description: 'Mobile BFF service traffic',
|
||
visibility: SavedFilterVisibilityType.Shared,
|
||
operationNames: allOpNames.slice(50, 80),
|
||
clientFilters: [{ name: 'mobile-backend-for-frontend-service' }],
|
||
dateRange: { from: 'now-30d', to: 'now' },
|
||
views: 118,
|
||
},
|
||
{
|
||
name: 'Error Investigation',
|
||
description: 'Operations with known error patterns',
|
||
visibility: SavedFilterVisibilityType.Private,
|
||
operationNames: allOpNames.slice(80, 90),
|
||
clientFilters: [{ name: 'android', versions: ['3.0.0'] }],
|
||
dateRange: { from: 'now-7d', to: 'now' },
|
||
views: 7,
|
||
},
|
||
{
|
||
name: 'Character Detail Queries',
|
||
description: 'All character detail operations',
|
||
visibility: SavedFilterVisibilityType.Shared,
|
||
operationNames: allOpNames.filter(n => n.startsWith('GetCharacterDetails')),
|
||
dateRange: { from: 'now-90d', to: 'now' },
|
||
views: 195,
|
||
},
|
||
{
|
||
name: 'Deep Queries',
|
||
description: 'Queries with nested friend relationships',
|
||
visibility: SavedFilterVisibilityType.Shared,
|
||
operationNames: allOpNames.slice(3, 8),
|
||
dateRange: { from: 'now-14d', to: 'now' },
|
||
views: 43,
|
||
},
|
||
{
|
||
name: 'iOS Latest Versions',
|
||
description: 'Recent iOS app versions only',
|
||
visibility: SavedFilterVisibilityType.Private,
|
||
operationNames: allOpNames.slice(0, 15),
|
||
clientFilters: [{ name: 'ios', versions: ['2.20.0', '2.24.0'] }],
|
||
dateRange: { from: 'now-7d', to: 'now' },
|
||
views: 31,
|
||
},
|
||
{
|
||
name: 'Analytics Worker',
|
||
description: 'Background analytics service operations',
|
||
visibility: SavedFilterVisibilityType.Shared,
|
||
operationNames: allOpNames.filter(n => n.startsWith('Analytics')),
|
||
clientFilters: [{ name: 'analytics-pipeline-worker-v2' }],
|
||
dateRange: { from: 'now-30d', to: 'now' },
|
||
views: 12,
|
||
},
|
||
{
|
||
name: 'Playground Exploration',
|
||
description: 'Ad-hoc queries from GraphQL Playground',
|
||
visibility: SavedFilterVisibilityType.Private,
|
||
operationNames: allOpNames.slice(100, 120),
|
||
clientFilters: [{ name: 'graphql-playground' }],
|
||
dateRange: { from: 'now-1d', to: 'now' },
|
||
views: 5,
|
||
},
|
||
{
|
||
name: 'Droid & Human Types',
|
||
description: 'Operations touching Droid and Human types',
|
||
visibility: SavedFilterVisibilityType.Shared,
|
||
operationNames: [
|
||
...allOpNames.filter(n => n.startsWith('GetDroid')),
|
||
...allOpNames.filter(n => n.startsWith('GetHuman')),
|
||
],
|
||
dateRange: { from: 'now-14d', to: 'now' },
|
||
views: 77,
|
||
},
|
||
{
|
||
name: 'Production Canary',
|
||
description: 'Canary release monitoring — latest client versions',
|
||
visibility: SavedFilterVisibilityType.Shared,
|
||
operationNames: allOpNames.slice(0, 10),
|
||
clientFilters: [
|
||
{ name: 'web-app', versions: ['1.14.0'] },
|
||
{ name: 'ios', versions: ['2.24.0'] },
|
||
{ name: 'android', versions: ['3.9.0'] },
|
||
],
|
||
dateRange: { from: 'now-7d', to: 'now' },
|
||
views: 203,
|
||
},
|
||
];
|
||
|
||
// Create all filters and collect results
|
||
const createdFilters: Array<{ name: string; id: string; views: number }> = [];
|
||
for (const def of savedFilterDefs) {
|
||
const ops = resolveOps(def.operationNames);
|
||
const result = await createSavedFilter({
|
||
name: def.name,
|
||
description: def.description,
|
||
visibility: def.visibility,
|
||
insightsFilter: {
|
||
operationHashes: ops,
|
||
...(def.clientFilters ? { clientFilters: def.clientFilters } : {}),
|
||
...(def.dateRange ? { dateRange: def.dateRange } : {}),
|
||
},
|
||
});
|
||
if (result.ok) {
|
||
createdFilters.push({ name: def.name, id: result.ok.savedFilter.id, views: def.views });
|
||
console.log(
|
||
` Created: "${def.name}" (${result.ok.savedFilter.id}) — ${ops.length} ops, ${def.visibility}`,
|
||
);
|
||
} else {
|
||
console.error(` Failed to create "${def.name}"`);
|
||
}
|
||
}
|
||
|
||
// Track views to populate view counts
|
||
console.log(`👁️ Tracking views for ${createdFilters.length} filters...`);
|
||
for (const filter of createdFilters) {
|
||
for (let i = 0; i < filter.views; i++) {
|
||
await trackSavedFilterView(filter.id);
|
||
}
|
||
console.log(` "${filter.name}": ${filter.views} views`);
|
||
}
|
||
|
||
console.log(`
|
||
✅ Seed complete!
|
||
|
||
Credentials:
|
||
Email: ${ownerEmail}
|
||
Password: ${password}
|
||
|
||
Navigate to:
|
||
http://localhost:3000/${organization.slug}/${project.slug}/${target.slug}/insights
|
||
http://localhost:3000/${organization.slug}/${project.slug}/${target.slug}/insights/manage-filters
|
||
`);
|
||
}
|
||
|
||
main().catch(err => {
|
||
console.error('Seed failed:', err);
|
||
process.exit(1);
|
||
});
|