@grafana/create-plugin 7.15.1 → 7.17.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/CHANGELOG.md +20 -0
- package/dist/codemods/additions/prompts/docs-content.md +1 -1
- package/dist/codemods/additions/scripts/experimental-app-sdk.js +40 -0
- package/dist/codemods/migrations/migrations.js +7 -1
- package/dist/codemods/migrations/scripts/019-remove-glob-dependency.js +93 -0
- package/dist/codemods/utils.ast.js +44 -1
- package/package.json +2 -1
- package/src/codemods/additions/prompts/docs-content.md +1 -1
- package/src/codemods/additions/scripts/docs.test.ts +39 -3
- package/src/codemods/additions/scripts/experimental-app-sdk.test.ts +59 -2
- package/src/codemods/additions/scripts/experimental-app-sdk.ts +55 -0
- package/src/codemods/migrations/migrations.ts +7 -1
- package/src/codemods/migrations/scripts/019-remove-glob-dependency.test.ts +146 -0
- package/src/codemods/migrations/scripts/019-remove-glob-dependency.ts +121 -0
- package/src/codemods/utils.ast.test.ts +151 -0
- package/src/codemods/utils.ast.ts +60 -0
- package/templates/common/.config/bundler/utils.ts +10 -9
- package/templates/common/_package.json +1 -2
- package/templates/docs/app/docs/AGENTS.md +2 -0
- package/templates/docs/app/docs/configure.md +4 -0
- package/templates/docs/app/docs/dashboards.md +4 -0
- package/templates/docs/app/docs/index.md +5 -1
- package/templates/docs/app/docs/troubleshooting.md +5 -1
- package/templates/docs/datasource/docs/AGENTS.md +2 -0
- package/templates/docs/datasource/docs/configure.md +4 -0
- package/templates/docs/datasource/docs/dashboards.md +4 -0
- package/templates/docs/datasource/docs/troubleshooting.md +5 -1
- package/templates/docs/panel/docs/AGENTS.md +2 -0
- package/templates/docs/panel/docs/data-formats.md +4 -0
- package/templates/docs/panel/docs/examples.md +4 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import migrate from './019-remove-glob-dependency.js';
|
|
4
|
+
import { createDefaultContext } from '../../test-utils.js';
|
|
5
|
+
|
|
6
|
+
const BUNDLER_UTILS_PATH = '.config/bundler/utils.ts';
|
|
7
|
+
const WEBPACK_UTILS_PATH = '.config/webpack/utils.ts';
|
|
8
|
+
|
|
9
|
+
const CURRENT_UTILS = readFileSync(
|
|
10
|
+
new URL('../../../../templates/common/.config/bundler/utils.ts', import.meta.url),
|
|
11
|
+
'utf-8'
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
const NEW_GLOB_IMPORT = "import { glob } from 'node:fs/promises';";
|
|
15
|
+
const CONSTANTS_IMPORT = "import { SOURCE_DIR } from './constants.ts';";
|
|
16
|
+
|
|
17
|
+
// The migration appends the new import after the existing ones, so it lands below the constants import.
|
|
18
|
+
const MIGRATED_UTILS = CURRENT_UTILS.replace(`${NEW_GLOB_IMPORT}\n`, '').replace(
|
|
19
|
+
CONSTANTS_IMPORT,
|
|
20
|
+
`${CONSTANTS_IMPORT}\n${NEW_GLOB_IMPORT}`
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
// The template's utils.ts as scaffolded before the glob package was removed.
|
|
24
|
+
const SCAFFOLDED_UTILS = CURRENT_UTILS.replace(
|
|
25
|
+
"import { glob } from 'node:fs/promises';",
|
|
26
|
+
"import { glob } from 'glob';"
|
|
27
|
+
).replace(
|
|
28
|
+
/export async function getEntries\(\) \{[\s\S]*?\n {2}return plugins\.reduce/,
|
|
29
|
+
`export async function getEntries() {
|
|
30
|
+
const pluginsJson = await glob('**/src/**/plugin.json', { absolute: true });
|
|
31
|
+
|
|
32
|
+
const plugins = await Promise.all(
|
|
33
|
+
pluginsJson.map((pluginJson) => {
|
|
34
|
+
const folder = path.dirname(pluginJson);
|
|
35
|
+
return glob(\`\${folder}/module.{ts,tsx,js,jsx}\`, { absolute: true });
|
|
36
|
+
})
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
return plugins.reduce`
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
function createContextWithGlob(utilsPath = BUNDLER_UTILS_PATH, utilsContent = SCAFFOLDED_UTILS) {
|
|
43
|
+
const context = createDefaultContext();
|
|
44
|
+
context.addFile(utilsPath, utilsContent);
|
|
45
|
+
context.updateFile(
|
|
46
|
+
'package.json',
|
|
47
|
+
JSON.stringify({ devDependencies: { '@grafana/tsconfig': '^2.0.1', glob: '^11.1.0' } }, null, 2)
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
return context;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getDevDependencies(context: ReturnType<typeof createDefaultContext>) {
|
|
54
|
+
return JSON.parse(context.getFile('package.json') ?? '{}').devDependencies;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('Migration - remove glob dependency', () => {
|
|
58
|
+
it('should be idempotent', async () => {
|
|
59
|
+
const context = createContextWithGlob();
|
|
60
|
+
|
|
61
|
+
await expect(migrate).toBeIdempotent(context);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should migrate the scaffolded utils to the current template', () => {
|
|
65
|
+
const context = migrate(createContextWithGlob());
|
|
66
|
+
|
|
67
|
+
expect(context.getFile(BUNDLER_UTILS_PATH)).toBe(MIGRATED_UTILS);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should migrate the legacy webpack utils location', () => {
|
|
71
|
+
const context = migrate(createContextWithGlob(WEBPACK_UTILS_PATH));
|
|
72
|
+
|
|
73
|
+
expect(context.getFile(WEBPACK_UTILS_PATH)).toBe(MIGRATED_UTILS);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should remove glob from package.json', () => {
|
|
77
|
+
const context = migrate(createContextWithGlob());
|
|
78
|
+
|
|
79
|
+
expect(getDevDependencies(context)).toEqual({ '@grafana/tsconfig': '^2.0.1' });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should migrate a customised getEntries that calls glob', () => {
|
|
83
|
+
const customisedUtils = SCAFFOLDED_UTILS.replace(
|
|
84
|
+
'{ absolute: true });\n\n const plugins',
|
|
85
|
+
"{ absolute: true, ignore: ['node_modules/**'] });\n\n const plugins"
|
|
86
|
+
);
|
|
87
|
+
const context = migrate(createContextWithGlob(BUNDLER_UTILS_PATH, customisedUtils));
|
|
88
|
+
|
|
89
|
+
expect(context.getFile(BUNDLER_UTILS_PATH)).toBe(MIGRATED_UTILS);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should skip a getEntries that does not call glob', () => {
|
|
93
|
+
const noGlobCallUtils = SCAFFOLDED_UTILS.replace(
|
|
94
|
+
"const pluginsJson = await glob('**/src/**/plugin.json', { absolute: true });",
|
|
95
|
+
"const pluginsJson = ['src/plugin.json'];"
|
|
96
|
+
).replace('return glob(', 'return Promise.resolve(');
|
|
97
|
+
const context = createContextWithGlob(BUNDLER_UTILS_PATH, noGlobCallUtils);
|
|
98
|
+
|
|
99
|
+
migrate(context);
|
|
100
|
+
|
|
101
|
+
expect(context.getFile(BUNDLER_UTILS_PATH)).toBe(noGlobCallUtils);
|
|
102
|
+
expect(getDevDependencies(context).glob).toBe('^11.1.0');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should skip an aliased glob import', () => {
|
|
106
|
+
const aliasedUtils = SCAFFOLDED_UTILS.replace("import { glob } from 'glob';", "import { glob as g } from 'glob';")
|
|
107
|
+
.replaceAll('await glob(', 'await g(')
|
|
108
|
+
.replace('return glob(', 'return g(');
|
|
109
|
+
const context = createContextWithGlob(BUNDLER_UTILS_PATH, aliasedUtils);
|
|
110
|
+
|
|
111
|
+
migrate(context);
|
|
112
|
+
|
|
113
|
+
expect(context.getFile(BUNDLER_UTILS_PATH)).toBe(aliasedUtils);
|
|
114
|
+
expect(getDevDependencies(context).glob).toBe('^11.1.0');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should keep other glob specifiers and the dependency', () => {
|
|
118
|
+
const mixedUtils = SCAFFOLDED_UTILS.replace(
|
|
119
|
+
"import { glob } from 'glob';",
|
|
120
|
+
"import { glob, globSync } from 'glob';"
|
|
121
|
+
);
|
|
122
|
+
const context = migrate(createContextWithGlob(BUNDLER_UTILS_PATH, mixedUtils));
|
|
123
|
+
const migrated = context.getFile(BUNDLER_UTILS_PATH);
|
|
124
|
+
|
|
125
|
+
expect(migrated).toContain("import { globSync } from 'glob';");
|
|
126
|
+
expect(migrated).toContain("import { glob } from 'node:fs/promises';");
|
|
127
|
+
expect(migrated).toContain("for await (const pluginJson of glob('**/src/**/plugin.json'))");
|
|
128
|
+
expect(getDevDependencies(context).glob).toBe('^11.1.0');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should leave files without a glob import unchanged', () => {
|
|
132
|
+
const context = createContextWithGlob(BUNDLER_UTILS_PATH, CURRENT_UTILS);
|
|
133
|
+
|
|
134
|
+
migrate(context);
|
|
135
|
+
|
|
136
|
+
expect(context.getFile(BUNDLER_UTILS_PATH)).toBe(CURRENT_UTILS);
|
|
137
|
+
expect(getDevDependencies(context).glob).toBe('^11.1.0');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should not fail when the utils file cannot be parsed', () => {
|
|
141
|
+
const context = createContextWithGlob(BUNDLER_UTILS_PATH, "import { glob } from 'glob';\nexport function {");
|
|
142
|
+
|
|
143
|
+
expect(() => migrate(context)).not.toThrow();
|
|
144
|
+
expect(getDevDependencies(context).glob).toBe('^11.1.0');
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { Context } from '../../context.js';
|
|
2
|
+
import * as recast from 'recast';
|
|
3
|
+
import {
|
|
4
|
+
callsFunction,
|
|
5
|
+
createImport,
|
|
6
|
+
findImports,
|
|
7
|
+
findFunctionDeclaration,
|
|
8
|
+
findNamedImport,
|
|
9
|
+
insertImports,
|
|
10
|
+
parseAsTypescript,
|
|
11
|
+
printAST,
|
|
12
|
+
} from '../../utils.ast.js';
|
|
13
|
+
import { migrationsDebug, removeDependenciesFromPackageJson } from '../../utils.js';
|
|
14
|
+
|
|
15
|
+
const OLD_PACKAGE_NAME = 'glob';
|
|
16
|
+
const NEW_PACKAGE_NAME = 'node:fs/promises';
|
|
17
|
+
|
|
18
|
+
const UTILS_PATHS = ['.config/webpack/utils.ts', '.config/bundler/utils.ts'];
|
|
19
|
+
|
|
20
|
+
const GET_ENTRIES_SOURCE = `export async function getEntries() {
|
|
21
|
+
const plugins: string[][] = [];
|
|
22
|
+
for await (const pluginJson of glob('**/src/**/plugin.json')) {
|
|
23
|
+
const folder = path.dirname(pluginJson);
|
|
24
|
+
const modules: string[] = [];
|
|
25
|
+
for await (const module of glob(\`\${folder}/module.{ts,tsx,js,jsx}\`)) {
|
|
26
|
+
modules.push(path.resolve(module));
|
|
27
|
+
}
|
|
28
|
+
plugins.push(modules);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return plugins.reduce<Record<string, string>>((result, modules) => {
|
|
32
|
+
return modules.reduce((innerResult, module) => {
|
|
33
|
+
const pluginPath = path.dirname(module);
|
|
34
|
+
const pluginName = path.relative(process.cwd(), pluginPath).replace(/src\\/?/i, '');
|
|
35
|
+
const entryName = pluginName === '' ? 'module' : \`\${pluginName}/module\`;
|
|
36
|
+
|
|
37
|
+
innerResult[entryName] = module;
|
|
38
|
+
return innerResult;
|
|
39
|
+
}, result);
|
|
40
|
+
}, {});
|
|
41
|
+
}`;
|
|
42
|
+
|
|
43
|
+
export default function migrate(context: Context) {
|
|
44
|
+
const updatedPaths = UTILS_PATHS.filter((filePath) => replaceGlobWithFsGlob(context, filePath));
|
|
45
|
+
if (updatedPaths.length === 0) {
|
|
46
|
+
return context;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const isStillUsed = UTILS_PATHS.some((filePath) => {
|
|
50
|
+
const source = context.getFile(filePath);
|
|
51
|
+
if (!source) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
const parsed = parseAsTypescript(source);
|
|
55
|
+
return !parsed.success || findImports(parsed.ast, OLD_PACKAGE_NAME).length > 0;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (context.doesFileExist('package.json') && !isStillUsed) {
|
|
59
|
+
removeDependenciesFromPackageJson(context, [OLD_PACKAGE_NAME], [OLD_PACKAGE_NAME]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return context;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function replaceGlobWithFsGlob(context: Context, utilsPath: string): boolean {
|
|
66
|
+
if (!context.doesFileExist(utilsPath)) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const source = context.getFile(utilsPath);
|
|
71
|
+
if (!source || !source.includes(OLD_PACKAGE_NAME)) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const parsed = parseAsTypescript(source);
|
|
76
|
+
if (!parsed.success) {
|
|
77
|
+
migrationsDebug(`Failed to parse ${utilsPath}. Error: ${parsed.error.message}`);
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const globImport = findNamedImport(parsed.ast, OLD_PACKAGE_NAME, 'glob');
|
|
82
|
+
if (!globImport) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const getEntries = findFunctionDeclaration(parsed.ast, 'getEntries');
|
|
87
|
+
if (!getEntries) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The new body calls glob directly, so an aliased import (and its calls) is left alone.
|
|
92
|
+
if (!callsFunction(getEntries.body, 'glob')) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const { declaration, specifier } = globImport;
|
|
97
|
+
declaration.specifiers = (declaration.specifiers ?? []).filter((candidate) => candidate !== specifier);
|
|
98
|
+
if (declaration.specifiers.length === 0) {
|
|
99
|
+
parsed.ast.program.body.splice(parsed.ast.program.body.indexOf(declaration), 1);
|
|
100
|
+
}
|
|
101
|
+
insertImports(parsed.ast, [createImport({ named: [{ name: 'glob' }] }, NEW_PACKAGE_NAME)]);
|
|
102
|
+
|
|
103
|
+
getEntries.body = getFunctionBody(GET_ENTRIES_SOURCE);
|
|
104
|
+
|
|
105
|
+
context.updateFile(utilsPath, printAST(parsed.ast));
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function getFunctionBody(source: string): recast.types.namedTypes.BlockStatement {
|
|
110
|
+
const parsed = parseAsTypescript(source);
|
|
111
|
+
if (!parsed.success) {
|
|
112
|
+
throw parsed.error;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const [statement] = parsed.ast.program.body;
|
|
116
|
+
if (statement?.type !== 'ExportNamedDeclaration' || statement.declaration?.type !== 'FunctionDeclaration') {
|
|
117
|
+
throw new Error('Failed to parse target function body');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return statement.declaration.body;
|
|
121
|
+
}
|
|
@@ -7,6 +7,10 @@ import {
|
|
|
7
7
|
findObjectProperty,
|
|
8
8
|
createImport,
|
|
9
9
|
insertImports,
|
|
10
|
+
findImports,
|
|
11
|
+
findNamedImport,
|
|
12
|
+
findFunctionDeclaration,
|
|
13
|
+
callsFunction,
|
|
10
14
|
} from './utils.ast.js';
|
|
11
15
|
|
|
12
16
|
// Helper to create a simple test AST from source code
|
|
@@ -31,6 +35,58 @@ function findFirstNode<T extends recast.types.namedTypes.Node>(ast: recast.types
|
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
describe('utils.ast', () => {
|
|
38
|
+
describe('Declaration Utilities', () => {
|
|
39
|
+
describe('findFunctionDeclaration', () => {
|
|
40
|
+
it('should find a function declaration by name', () => {
|
|
41
|
+
const ast = createTestAST(`function foo() {}\nfunction bar() {}`);
|
|
42
|
+
|
|
43
|
+
const declaration = findFunctionDeclaration(ast, 'bar');
|
|
44
|
+
|
|
45
|
+
expect(declaration?.id?.name).toBe('bar');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should find an exported function declaration', () => {
|
|
49
|
+
const ast = createTestAST(`export async function getEntries() {}`);
|
|
50
|
+
|
|
51
|
+
const declaration = findFunctionDeclaration(ast, 'getEntries');
|
|
52
|
+
|
|
53
|
+
expect(declaration?.async).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should return null when the function is not declared', () => {
|
|
57
|
+
const ast = createTestAST(`const getEntries = () => {};`);
|
|
58
|
+
|
|
59
|
+
expect(findFunctionDeclaration(ast, 'getEntries')).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('callsFunction', () => {
|
|
64
|
+
it('should return true when the function is called', () => {
|
|
65
|
+
const ast = createTestAST(`async function run() { const files = await glob('**/*.ts'); }`);
|
|
66
|
+
|
|
67
|
+
expect(callsFunction(ast, 'glob')).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should return true for nested calls', () => {
|
|
71
|
+
const ast = createTestAST(`items.map((item) => wrap(glob(item)));`);
|
|
72
|
+
|
|
73
|
+
expect(callsFunction(ast, 'glob')).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should return false when the function is only referenced', () => {
|
|
77
|
+
const ast = createTestAST(`const fn = glob;`);
|
|
78
|
+
|
|
79
|
+
expect(callsFunction(ast, 'glob')).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should return false for a method with the same name', () => {
|
|
83
|
+
const ast = createTestAST(`fs.glob('**/*.ts');`);
|
|
84
|
+
|
|
85
|
+
expect(callsFunction(ast, 'glob')).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
34
90
|
describe('Property Utilities', () => {
|
|
35
91
|
describe('isProperty', () => {
|
|
36
92
|
it('should return true for Property nodes', () => {
|
|
@@ -191,5 +247,100 @@ describe('utils.ast', () => {
|
|
|
191
247
|
expect(output).toBe(`import React from 'react';\nimport { map } from 'lodash';\nconst x = 1;`);
|
|
192
248
|
});
|
|
193
249
|
});
|
|
250
|
+
|
|
251
|
+
describe('findImports', () => {
|
|
252
|
+
it('should return an empty array when the package is not imported', () => {
|
|
253
|
+
const ast = createTestAST(`import React from 'react';\nconst x = 1;`);
|
|
254
|
+
|
|
255
|
+
expect(findImports(ast, 'glob')).toEqual([]);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('should return the matching import declaration', () => {
|
|
259
|
+
const ast = createTestAST(`import React from 'react';\nimport { glob } from 'glob';`);
|
|
260
|
+
|
|
261
|
+
const imports = findImports(ast, 'glob');
|
|
262
|
+
|
|
263
|
+
expect(imports).toHaveLength(1);
|
|
264
|
+
expect(imports[0].source.value).toBe('glob');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('should return every declaration importing the package', () => {
|
|
268
|
+
const ast = createTestAST(`import { glob } from 'glob';\nimport type { GlobOptions } from 'glob';`);
|
|
269
|
+
|
|
270
|
+
expect(findImports(ast, 'glob')).toHaveLength(2);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('should not match packages with a similar name', () => {
|
|
274
|
+
const ast = createTestAST(`import globParent from 'glob-parent';\nimport { glob } from 'node:fs/promises';`);
|
|
275
|
+
|
|
276
|
+
expect(findImports(ast, 'glob')).toEqual([]);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe('findNamedImport', () => {
|
|
281
|
+
it('should find a named import', () => {
|
|
282
|
+
const ast = createTestAST(`import { glob } from 'glob';`);
|
|
283
|
+
|
|
284
|
+
const match = findNamedImport(ast, 'glob', 'glob');
|
|
285
|
+
|
|
286
|
+
expect(match).toBeDefined();
|
|
287
|
+
expect(match?.declaration.source.value).toBe('glob');
|
|
288
|
+
expect(match?.specifier.imported.name).toBe('glob');
|
|
289
|
+
expect(match?.specifier.local?.name).toBe('glob');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('should find an aliased named import by its imported name', () => {
|
|
293
|
+
const ast = createTestAST(`import { glob as g } from 'glob';`);
|
|
294
|
+
|
|
295
|
+
const match = findNamedImport(ast, 'glob', 'glob');
|
|
296
|
+
|
|
297
|
+
expect(match).toBeDefined();
|
|
298
|
+
expect(match?.specifier.local?.name).toBe('g');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('should find the specifier among other specifiers', () => {
|
|
302
|
+
const ast = createTestAST(`import { globSync, glob } from 'glob';`);
|
|
303
|
+
|
|
304
|
+
const match = findNamedImport(ast, 'glob', 'glob');
|
|
305
|
+
|
|
306
|
+
expect(match).toBeDefined();
|
|
307
|
+
expect(match?.declaration.specifiers).toHaveLength(2);
|
|
308
|
+
expect(match?.specifier.imported.name).toBe('glob');
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('should find the specifier in a later declaration of the same package', () => {
|
|
312
|
+
const ast = createTestAST(`import type { GlobOptions } from 'glob';\nimport { glob } from 'glob';`);
|
|
313
|
+
const [, secondDeclaration] = findImports(ast, 'glob');
|
|
314
|
+
|
|
315
|
+
const match = findNamedImport(ast, 'glob', 'glob');
|
|
316
|
+
|
|
317
|
+
expect(match).toBeDefined();
|
|
318
|
+
expect(match?.declaration).toBe(secondDeclaration);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('should return undefined when only other named imports exist', () => {
|
|
322
|
+
const ast = createTestAST(`import { globSync } from 'glob';`);
|
|
323
|
+
|
|
324
|
+
expect(findNamedImport(ast, 'glob', 'glob')).toBeUndefined();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('should return undefined for a default import', () => {
|
|
328
|
+
const ast = createTestAST(`import glob from 'glob';`);
|
|
329
|
+
|
|
330
|
+
expect(findNamedImport(ast, 'glob', 'glob')).toBeUndefined();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('should return undefined for a namespace import', () => {
|
|
334
|
+
const ast = createTestAST(`import * as glob from 'glob';`);
|
|
335
|
+
|
|
336
|
+
expect(findNamedImport(ast, 'glob', 'glob')).toBeUndefined();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('should return undefined when the package is not imported', () => {
|
|
340
|
+
const ast = createTestAST(`import { glob } from 'node:fs/promises';`);
|
|
341
|
+
|
|
342
|
+
expect(findNamedImport(ast, 'glob', 'glob')).toBeUndefined();
|
|
343
|
+
});
|
|
344
|
+
});
|
|
194
345
|
});
|
|
195
346
|
});
|
|
@@ -45,6 +45,42 @@ export function findVariableDeclaration(
|
|
|
45
45
|
return found;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
export function findFunctionDeclaration(
|
|
49
|
+
ast: recast.types.ASTNode,
|
|
50
|
+
functionName: string
|
|
51
|
+
): recast.types.namedTypes.FunctionDeclaration | null {
|
|
52
|
+
let found = null;
|
|
53
|
+
|
|
54
|
+
recast.types.visit(ast, {
|
|
55
|
+
visitFunctionDeclaration(path) {
|
|
56
|
+
if (path.node.id?.name === functionName) {
|
|
57
|
+
found = path.node;
|
|
58
|
+
return false; // Stop traversal
|
|
59
|
+
}
|
|
60
|
+
return this.traverse(path);
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return found;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function callsFunction(ast: recast.types.ASTNode, functionName: string): boolean {
|
|
68
|
+
let isCalled = false;
|
|
69
|
+
|
|
70
|
+
recast.types.visit(ast, {
|
|
71
|
+
visitCallExpression(path) {
|
|
72
|
+
const { callee } = path.node;
|
|
73
|
+
if (callee.type === 'Identifier' && callee.name === functionName) {
|
|
74
|
+
isCalled = true;
|
|
75
|
+
return false; // Stop traversal
|
|
76
|
+
}
|
|
77
|
+
return this.traverse(path);
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return isCalled;
|
|
82
|
+
}
|
|
83
|
+
|
|
48
84
|
export function isProperty(
|
|
49
85
|
node: recast.types.ASTNode
|
|
50
86
|
): node is recast.types.namedTypes.Property | recast.types.namedTypes.ObjectProperty {
|
|
@@ -108,3 +144,27 @@ export function insertImports(
|
|
|
108
144
|
ast.program.body.unshift(...imports);
|
|
109
145
|
}
|
|
110
146
|
}
|
|
147
|
+
|
|
148
|
+
export function findImports(
|
|
149
|
+
ast: recast.types.namedTypes.File,
|
|
150
|
+
packageName: string
|
|
151
|
+
): recast.types.namedTypes.ImportDeclaration[] {
|
|
152
|
+
return ast.program.body.filter(
|
|
153
|
+
(node): node is recast.types.namedTypes.ImportDeclaration =>
|
|
154
|
+
node.type === 'ImportDeclaration' && node.source.value === packageName
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function findNamedImport(ast: recast.types.namedTypes.File, packageName: string, importedName: string) {
|
|
159
|
+
for (const declaration of findImports(ast, packageName)) {
|
|
160
|
+
const specifier = declaration.specifiers?.find(
|
|
161
|
+
(candidate): candidate is recast.types.namedTypes.ImportSpecifier =>
|
|
162
|
+
candidate.type === 'ImportSpecifier' && candidate.imported.name === importedName
|
|
163
|
+
);
|
|
164
|
+
if (specifier) {
|
|
165
|
+
return { declaration, specifier };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
@@ -2,7 +2,7 @@ import fs from 'fs';
|
|
|
2
2
|
import process from 'process';
|
|
3
3
|
import os from 'os';
|
|
4
4
|
import path from 'path';
|
|
5
|
-
import { glob } from '
|
|
5
|
+
import { glob } from 'node:fs/promises';
|
|
6
6
|
import { SOURCE_DIR } from './constants.ts';
|
|
7
7
|
|
|
8
8
|
export function isWSL() {
|
|
@@ -46,14 +46,15 @@ export function hasReadme() {
|
|
|
46
46
|
// Support bundling nested plugins by finding all plugin.json files in src directory
|
|
47
47
|
// then checking for a sibling module.[jt]sx? file.
|
|
48
48
|
export async function getEntries() {
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
|
|
49
|
+
const plugins: string[][] = [];
|
|
50
|
+
for await (const pluginJson of glob('**/src/**/plugin.json')) {
|
|
51
|
+
const folder = path.dirname(pluginJson);
|
|
52
|
+
const modules: string[] = [];
|
|
53
|
+
for await (const module of glob(`${folder}/module.{ts,tsx,js,jsx}`)) {
|
|
54
|
+
modules.push(path.resolve(module));
|
|
55
|
+
}
|
|
56
|
+
plugins.push(modules);
|
|
57
|
+
}
|
|
57
58
|
|
|
58
59
|
return plugins.reduce<Record<string, string>>((result, modules) => {
|
|
59
60
|
return modules.reduce((innerResult, module) => {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"license": "Apache-2.0",
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"@grafana/eslint-config": "^10.0.0",
|
|
20
|
-
"@grafana/plugin-e2e": "^3.15.
|
|
20
|
+
"@grafana/plugin-e2e": "^3.15.2",
|
|
21
21
|
"@grafana/sign-plugin": "^3.3.3",
|
|
22
22
|
"@grafana/tsconfig": "^2.2.0",
|
|
23
23
|
"@playwright/test": "^1.62.1",{{#if useExperimentalRspack}}
|
|
@@ -46,7 +46,6 @@
|
|
|
46
46
|
"eslint-plugin-react-hooks": "^7.1.0",{{#if useExperimentalRspack}}
|
|
47
47
|
"eslint-webpack-plugin": "^5.0.0",{{/if}}{{#unless useExperimentalRspack}}
|
|
48
48
|
"fork-ts-checker-webpack-plugin": "^9.1.0",{{/unless}}
|
|
49
|
-
"glob": "^11.1.0",
|
|
50
49
|
"identity-obj-proxy": "^3.0.0",
|
|
51
50
|
"imports-loader": "^5.0.0",
|
|
52
51
|
"intersection-observer": "^0.12.2",
|
|
@@ -108,6 +108,8 @@ To fill a section:
|
|
|
108
108
|
Briefs are not shown in the preview or the catalog, so a section you haven't written looks like a bare
|
|
109
109
|
heading. `docs:validate` lists every section that still has only its brief.
|
|
110
110
|
|
|
111
|
+
Text between `<!-- snippet:... -->` and `<!-- /snippet:... -->` markers is maintained by Grafana. Never edit inside it - refer to `snippets.md` in the guides.
|
|
112
|
+
|
|
111
113
|
## Validate
|
|
112
114
|
|
|
113
115
|
```bash
|
|
@@ -20,12 +20,16 @@ Before you configure the app, ensure you have:
|
|
|
20
20
|
|
|
21
21
|
## Open the configuration page
|
|
22
22
|
|
|
23
|
+
<!-- snippet:open-app-config -->
|
|
24
|
+
|
|
23
25
|
To open the {{{pluginName}}} configuration page:
|
|
24
26
|
|
|
25
27
|
1. Click **Administration** > **Plugins and data** > **Plugins** in the left-side menu.
|
|
26
28
|
1. Enter `{{{pluginName}}}` in the search bar.
|
|
27
29
|
1. Select **{{{pluginName}}}**.
|
|
28
30
|
|
|
31
|
+
<!-- /snippet:open-app-config -->
|
|
32
|
+
|
|
29
33
|
<!-- section-brief
|
|
30
34
|
Fill this in: After the list above, add one sentence that names the tab to click, using the `title` of the page passed to `addConfigPage` in `src/module.ts(x)`, such as "Then click the **Configuration** tab." Keep it a sentence rather than another step of the list. If the app adds more than one config page, name each tab and what it holds.
|
|
31
35
|
-->
|
|
@@ -12,6 +12,8 @@ sidebar_position: 6
|
|
|
12
12
|
|
|
13
13
|
## Import a dashboard
|
|
14
14
|
|
|
15
|
+
<!-- snippet:import-app-dashboard -->
|
|
16
|
+
|
|
15
17
|
To import a dashboard that ships with {{{pluginName}}}:
|
|
16
18
|
|
|
17
19
|
1. Click **Administration** > **Plugins and data** > **Plugins** in the left-side menu.
|
|
@@ -22,6 +24,8 @@ To import a dashboard that ships with {{{pluginName}}}:
|
|
|
22
24
|
|
|
23
25
|
Imported dashboards don't update when you upgrade the plugin. When a plugin upgrade ships a newer version of a dashboard, click **Update** next to it. This overwrites any changes you made to it.
|
|
24
26
|
|
|
27
|
+
<!-- /snippet:import-app-dashboard -->
|
|
28
|
+
|
|
25
29
|
## Included dashboards
|
|
26
30
|
|
|
27
31
|
<!-- section-brief
|
|
@@ -24,6 +24,8 @@ Fill this in: Keep the list above. Add a bullet in the same **Label:** form for
|
|
|
24
24
|
|
|
25
25
|
## Enable the app
|
|
26
26
|
|
|
27
|
+
<!-- snippet:enable-app -->
|
|
28
|
+
|
|
27
29
|
To enable {{{pluginName}}}:
|
|
28
30
|
|
|
29
31
|
1. Click **Administration** > **Plugins and data** > **Plugins** in the left-side menu.
|
|
@@ -31,8 +33,10 @@ To enable {{{pluginName}}}:
|
|
|
31
33
|
1. Select **{{{pluginName}}}**.
|
|
32
34
|
1. Click **Enable**.
|
|
33
35
|
|
|
36
|
+
<!-- /snippet:enable-app -->
|
|
37
|
+
|
|
34
38
|
<!-- section-brief
|
|
35
|
-
Fill this in: If `src/plugin.json` sets `"autoEnabled": true`, the app is enabled when it's installed, so remove this section. Otherwise keep the steps above and remove only this brief.
|
|
39
|
+
Fill this in: If `src/plugin.json` sets `"autoEnabled": true`, the app is enabled when it's installed, so remove this section, including the steps and their snippet markers. Otherwise keep the steps above and remove only this brief.
|
|
36
40
|
-->
|
|
37
41
|
{{#if hasConfigPage}}
|
|
38
42
|
|
|
@@ -32,7 +32,9 @@ Open each H2 with one sentence on when its errors occur. Add an H3 per symptom,
|
|
|
32
32
|
For errors in the app, open the developer tools in your browser and check the console.
|
|
33
33
|
{{#if hasBackend}}
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
<!-- snippet:server-debug-logs -->
|
|
36
|
+
|
|
37
|
+
In self-managed Grafana, the Grafana server logs show more detail:
|
|
36
38
|
|
|
37
39
|
1. Set the log level to `debug` in the Grafana configuration file:
|
|
38
40
|
|
|
@@ -44,6 +46,8 @@ In self-managed Grafana, you can also find more detail in the Grafana server log
|
|
|
44
46
|
1. Restart Grafana and reproduce the error.
|
|
45
47
|
1. Look for log lines with `logger=plugin.{{{pluginId}}}`.
|
|
46
48
|
1. Set the log level back to `info` when you're done, to keep the logs small.
|
|
49
|
+
|
|
50
|
+
<!-- /snippet:server-debug-logs -->
|
|
47
51
|
{{/if}}
|
|
48
52
|
|
|
49
53
|
## Get additional help
|
|
@@ -102,6 +102,8 @@ To fill a section:
|
|
|
102
102
|
Briefs are not shown in the preview or the catalog, so a section you haven't written looks like a bare
|
|
103
103
|
heading. `docs:validate` lists every section that still has only its brief.
|
|
104
104
|
|
|
105
|
+
Text between `<!-- snippet:... -->` and `<!-- /snippet:... -->` markers is maintained by Grafana. Never edit inside it - refer to `snippets.md` in the guides.
|
|
106
|
+
|
|
105
107
|
## Validate
|
|
106
108
|
|
|
107
109
|
```bash
|
|
@@ -31,6 +31,8 @@ If the reader only needs credentials they already have, remove this section and
|
|
|
31
31
|
|
|
32
32
|
## Add the data source
|
|
33
33
|
|
|
34
|
+
<!-- snippet:add-data-source -->
|
|
35
|
+
|
|
34
36
|
To add the {{{pluginName}}} data source:
|
|
35
37
|
|
|
36
38
|
1. Click **Connections** in the left-side menu.
|
|
@@ -39,6 +41,8 @@ To add the {{{pluginName}}} data source:
|
|
|
39
41
|
1. Select **{{{pluginName}}}**.
|
|
40
42
|
1. Click **Add new data source**.
|
|
41
43
|
|
|
44
|
+
<!-- /snippet:add-data-source -->
|
|
45
|
+
|
|
42
46
|
## Configure settings
|
|
43
47
|
|
|
44
48
|
<!-- section-brief
|