Compare commits

..

2 Commits

Author SHA1 Message Date
Ashley Harrison
e9f164d9f2 undo changes to query library cause i cba dealing with enterprise rn 2026-01-14 17:20:37 +00:00
Ashley Harrison
86fc051c58 ref changes compatible with react 18 + react 19 2026-01-14 17:08:28 +00:00
66 changed files with 547 additions and 680 deletions

View File

@@ -22,7 +22,7 @@ import { getBarColorByDiff, getBarColorByPackage, getBarColorByValue } from './c
import { CollapseConfig, CollapsedMap, FlameGraphDataContainer, LevelItem } from './dataTransform';
type RenderOptions = {
canvasRef: RefObject<HTMLCanvasElement>;
canvasRef: RefObject<HTMLCanvasElement | null>;
data: FlameGraphDataContainer;
root: LevelItem;
direction: 'children' | 'parents';
@@ -373,7 +373,7 @@ function useColorFunction(
);
}
function useSetupCanvas(canvasRef: RefObject<HTMLCanvasElement>, wrapperWidth: number, numberOfLevels: number) {
function useSetupCanvas(canvasRef: RefObject<HTMLCanvasElement | null>, wrapperWidth: number, numberOfLevels: number) {
const [ctx, setCtx] = useState<CanvasRenderingContext2D>();
useEffect(() => {

View File

@@ -7,7 +7,7 @@ const CAUGHT_KEYS = ['ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', 'Tab'];
/** @internal */
export interface UseListFocusProps {
localRef: RefObject<HTMLUListElement>;
localRef: RefObject<HTMLUListElement | null>;
options: TimeOption[];
}

View File

@@ -1,7 +1,7 @@
import { RefObject, useRef } from 'react';
export function useFocus(): [RefObject<HTMLInputElement>, () => void] {
const ref = useRef<HTMLInputElement>(null);
export function useFocus(): [RefObject<HTMLInputElement | null>, () => void] {
const ref = useRef<HTMLInputElement | null>(null);
const setFocus = () => {
ref.current && ref.current.focus();
};

View File

@@ -6,7 +6,7 @@ const UNFOCUSED = -1;
/** @internal */
export interface UseMenuFocusProps {
localRef: RefObject<HTMLDivElement>;
localRef: RefObject<HTMLDivElement | null>;
isMenuOpen?: boolean;
close?: () => void;
onOpen?: (focusOnItem: (itemId: number) => void) => void;

View File

@@ -22,7 +22,7 @@ interface Props extends Omit<BoxProps, 'display' | 'direction' | 'element' | 'fl
*
* https://developers.grafana.com/ui/latest/index.html?path=/docs/layout-scrollcontainer--docs
*/
export const ScrollContainer = forwardRef<HTMLDivElement, PropsWithChildren<Props>>(
export const ScrollContainer = forwardRef<HTMLDivElement | null, PropsWithChildren<Props>>(
(
{
children,

View File

@@ -20,7 +20,7 @@ export interface TableCellTooltipProps {
field: Field;
getActions: (field: Field, rowIdx: number) => ActionModel[];
getTextColorForBackground: (bgColor: string) => string;
gridRef: RefObject<DataGridHandle>;
gridRef: RefObject<DataGridHandle | null>;
height: number;
placement?: TableCellTooltipPlacement;
renderer: TableCellRenderer;

View File

@@ -463,7 +463,7 @@ export function useColumnResize(
return dataGridResizeHandler;
}
export function useScrollbarWidth(ref: RefObject<DataGridHandle>, height: number) {
export function useScrollbarWidth(ref: RefObject<DataGridHandle | null>, height: number) {
const [scrollbarWidth, setScrollbarWidth] = useState(0);
useLayoutEffect(() => {

View File

@@ -135,7 +135,7 @@ export const Table = memo((props: Props) => {
// `useTableStateReducer`, which is needed to construct options for `useTable` (the hook that returns
// `toggleAllRowsExpanded`), and if we used a variable, that variable would be undefined at the time
// we initialize `useTableStateReducer`.
const toggleAllRowsExpandedRef = useRef<(value?: boolean) => void>();
const toggleAllRowsExpandedRef = useRef<((value?: boolean) => void) | null>(null);
// Internal react table state reducer
const stateReducer = useTableStateReducer({

View File

@@ -14,8 +14,8 @@ import { GrafanaTableState } from './types';
Select the scrollbar element from the VariableSizeList scope
*/
export function useFixScrollbarContainer(
variableSizeListScrollbarRef: React.RefObject<HTMLDivElement>,
tableDivRef: React.RefObject<HTMLDivElement>
variableSizeListScrollbarRef: React.RefObject<HTMLDivElement | null>,
tableDivRef: React.RefObject<HTMLDivElement | null>
) {
useEffect(() => {
if (variableSizeListScrollbarRef.current && tableDivRef.current) {
@@ -43,7 +43,7 @@ export function useFixScrollbarContainer(
*/
export function useResetVariableListSizeCache(
extendedState: GrafanaTableState,
listRef: React.RefObject<VariableSizeList>,
listRef: React.RefObject<VariableSizeList | null>,
data: DataFrame,
hasUniqueId: boolean
) {

View File

@@ -19,7 +19,7 @@ interface EventsCanvasProps {
}
export function EventsCanvas({ id, events, renderEventMarker, mapEventToXYCoords, config }: EventsCanvasProps) {
const plotInstance = useRef<uPlot>();
const plotInstance = useRef<uPlot | null>(null);
// render token required to re-render annotation markers. Rendering lines happens in uPlot and the props do not change
// so we need to force the re-render when the draw hook was performed by uPlot
const [renderToken, setRenderToken] = useState(0);

View File

@@ -140,7 +140,7 @@ export const TooltipPlugin2 = ({
const [{ plot, isHovering, isPinned, contents, style, dismiss }, setState] = useReducer(mergeState, null, initState);
const sizeRef = useRef<TooltipContainerSize>();
const sizeRef = useRef<TooltipContainerSize | null>(null);
const styles = useStyles2(getStyles, maxWidth);
const renderRef = useRef(render);

View File

@@ -96,7 +96,7 @@ export interface GraphNGState {
export class GraphNG extends Component<GraphNGProps, GraphNGState> {
static contextType = PanelContextRoot;
panelContext: PanelContext = {} as PanelContext;
private plotInstance: React.RefObject<uPlot>;
private plotInstance: React.RefObject<uPlot | null>;
private subscription = new Subscription();

View File

@@ -20,10 +20,8 @@ import (
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/manager/pluginfakes"
"github.com/grafana/grafana/pkg/plugins/manager/registry"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
"github.com/grafana/grafana/pkg/plugins/pluginassets/modulehash"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
@@ -82,8 +80,7 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.F
var pluginsAssets = passets
if pluginsAssets == nil {
sig := signature.ProvideService(pluginsCfg, statickey.New())
calc := modulehash.NewCalculator(pluginsCfg, registry.NewInMemory(), pluginsCDN, sig)
pluginsAssets = pluginassets.ProvideService(pluginsCfg, pluginsCDN, calc)
pluginsAssets = pluginassets.ProvideService(pluginsCfg, pluginsCDN, sig, pluginStore)
}
hs := &HTTPServer{
@@ -717,8 +714,6 @@ func newPluginAssets() func() *pluginassets.Service {
func newPluginAssetsWithConfig(pCfg *config.PluginManagementCfg) func() *pluginassets.Service {
return func() *pluginassets.Service {
cdn := pluginscdn.ProvideService(pCfg)
calc := modulehash.NewCalculator(pCfg, registry.NewInMemory(), cdn, signature.ProvideService(pCfg, statickey.New()))
return pluginassets.ProvideService(pCfg, cdn, calc)
return pluginassets.ProvideService(pCfg, pluginscdn.ProvideService(pCfg), signature.ProvideService(pCfg, statickey.New()), &pluginstore.FakePluginStore{})
}
}

View File

@@ -30,7 +30,6 @@ import (
"github.com/grafana/grafana/pkg/plugins/manager/registry"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
"github.com/grafana/grafana/pkg/plugins/pluginassets/modulehash"
"github.com/grafana/grafana/pkg/plugins/pluginerrs"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
@@ -850,8 +849,7 @@ func Test_PluginsSettings(t *testing.T) {
pCfg := &config.PluginManagementCfg{}
pluginCDN := pluginscdn.ProvideService(pCfg)
sig := signature.ProvideService(pCfg, statickey.New())
calc := modulehash.NewCalculator(pCfg, registry.NewInMemory(), pluginCDN, sig)
hs.pluginAssets = pluginassets.ProvideService(pCfg, pluginCDN, calc)
hs.pluginAssets = pluginassets.ProvideService(pCfg, pluginCDN, sig, hs.pluginStore)
hs.pluginErrorResolver = pluginerrs.ProvideStore(errTracker)
hs.pluginsUpdateChecker, err = updatemanager.ProvidePluginsService(
hs.Cfg,

View File

@@ -1,144 +0,0 @@
package modulehash
import (
"context"
"encoding/base64"
"encoding/hex"
"fmt"
"path"
"path/filepath"
"sync"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/log"
"github.com/grafana/grafana/pkg/plugins/manager/registry"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
)
type Calculator struct {
reg registry.Service
cfg *config.PluginManagementCfg
cdn *pluginscdn.Service
signature *signature.Signature
log log.Logger
moduleHashCache sync.Map
}
func NewCalculator(cfg *config.PluginManagementCfg, reg registry.Service, cdn *pluginscdn.Service, signature *signature.Signature) *Calculator {
return &Calculator{
cfg: cfg,
reg: reg,
cdn: cdn,
signature: signature,
log: log.New("modulehash"),
}
}
// ModuleHash returns the module.js SHA256 hash for a plugin in the format expected by the browser for SRI checks.
// The module hash is read from the plugin's MANIFEST.txt file.
// The plugin can also be a nested plugin.
// If the plugin is unsigned, an empty string is returned.
// The results are cached to avoid repeated reads from the MANIFEST.txt file.
func (c *Calculator) ModuleHash(ctx context.Context, pluginID, pluginVersion string) string {
p, ok := c.reg.Plugin(ctx, pluginID, pluginVersion)
if !ok {
c.log.Error("Failed to calculate module hash as plugin is not registered", "pluginId", pluginID)
return ""
}
k := c.moduleHashCacheKey(pluginID, pluginVersion)
cachedValue, ok := c.moduleHashCache.Load(k)
if ok {
return cachedValue.(string)
}
mh, err := c.moduleHash(ctx, p, "")
if err != nil {
c.log.Error("Failed to calculate module hash", "pluginId", p.ID, "error", err)
}
c.moduleHashCache.Store(k, mh)
return mh
}
// moduleHash is the underlying function for ModuleHash. See its documentation for more information.
// If the plugin is not a CDN plugin, the function will return an empty string.
// It will read the module hash from the MANIFEST.txt in the [[plugins.FS]] of the provided plugin.
// If childFSBase is provided, the function will try to get the hash from MANIFEST.txt for the provided children's
// module.js file, rather than for the provided plugin.
func (c *Calculator) moduleHash(ctx context.Context, p *plugins.Plugin, childFSBase string) (r string, err error) {
if !c.cfg.Features.SriChecksEnabled {
return "", nil
}
// Ignore unsigned plugins
if !p.Signature.IsValid() {
return "", nil
}
if p.Parent != nil {
// The module hash is contained within the parent's MANIFEST.txt file.
// For example, the parent's MANIFEST.txt will contain an entry similar to this:
//
// ```
// "datasource/module.js": "1234567890abcdef..."
// ```
//
// Recursively call moduleHash with the parent plugin and with the children plugin folder path
// to get the correct module hash for the nested plugin.
if childFSBase == "" {
childFSBase = p.FS.Base()
}
return c.moduleHash(ctx, p.Parent, childFSBase)
}
// Only CDN plugins are supported for SRI checks.
// CDN plugins have the version as part of the URL, which acts as a cache-buster.
// Needed due to: https://github.com/grafana/plugin-tools/pull/1426
// FS plugins build before this change will have SRI mismatch issues.
if !c.cdnEnabled(p.ID, p.FS) {
return "", nil
}
manifest, err := c.signature.ReadPluginManifestFromFS(ctx, p.FS)
if err != nil {
return "", fmt.Errorf("read plugin manifest: %w", err)
}
if !manifest.IsV2() {
return "", nil
}
var childPath string
if childFSBase != "" {
// Calculate the relative path of the child plugin folder from the parent plugin folder.
childPath, err = p.FS.Rel(childFSBase)
if err != nil {
return "", fmt.Errorf("rel path: %w", err)
}
// MANIFETS.txt uses forward slashes as path separators.
childPath = filepath.ToSlash(childPath)
}
moduleHash, ok := manifest.Files[path.Join(childPath, "module.js")]
if !ok {
return "", nil
}
return convertHashForSRI(moduleHash)
}
func (c *Calculator) cdnEnabled(pluginID string, fs plugins.FS) bool {
return c.cdn.PluginSupported(pluginID) || fs.Type().CDN()
}
// convertHashForSRI takes a SHA256 hash string and returns it as expected by the browser for SRI checks.
func convertHashForSRI(h string) (string, error) {
hb, err := hex.DecodeString(h)
if err != nil {
return "", fmt.Errorf("hex decode string: %w", err)
}
return "sha256-" + base64.StdEncoding.EncodeToString(hb), nil
}
// moduleHashCacheKey returns a unique key for the module hash cache.
func (c *Calculator) moduleHashCacheKey(pluginId, pluginVersion string) string {
return pluginId + ":" + pluginVersion
}

View File

@@ -1,459 +0,0 @@
package modulehash
import (
"context"
"fmt"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/manager/registry"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
)
func Test_ModuleHash(t *testing.T) {
const (
pluginID = "grafana-test-datasource"
parentPluginID = "grafana-test-app"
)
for _, tc := range []struct {
name string
features *config.Features
registry []*plugins.Plugin
// Can be used to configure plugin's fs
// fs cdn type = loaded from CDN with no files on disk
// fs local type = files on disk but served from CDN only if cdn=true
plugin string
// When true, set cdn=true in config
cdn bool
expModuleHash string
}{
{
name: "unsigned should not return module hash",
plugin: pluginID,
registry: []*plugins.Plugin{newPlugin(pluginID, withSignatureStatus(plugins.SignatureStatusUnsigned))},
cdn: false,
features: &config.Features{SriChecksEnabled: false},
expModuleHash: "",
},
{
plugin: pluginID,
registry: []*plugins.Plugin{newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid"))),
withClass(plugins.ClassExternal),
)},
cdn: true,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"),
},
{
plugin: pluginID,
registry: []*plugins.Plugin{newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid"))),
withClass(plugins.ClassExternal),
)},
cdn: true,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"),
},
{
plugin: pluginID,
registry: []*plugins.Plugin{newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid"))),
)},
cdn: false,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: "",
},
{
plugin: pluginID,
registry: []*plugins.Plugin{newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid"))),
)},
cdn: true,
features: &config.Features{SriChecksEnabled: false},
expModuleHash: "",
},
{
plugin: pluginID,
registry: []*plugins.Plugin{newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid"))),
)},
cdn: false,
features: &config.Features{SriChecksEnabled: false},
expModuleHash: "",
},
{
// parentPluginID (/)
// └── pluginID (/datasource)
name: "nested plugin should return module hash from parent MANIFEST.txt",
plugin: pluginID,
registry: []*plugins.Plugin{
newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid-nested", "datasource"))),
withParent(newPlugin(
parentPluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid-nested"))),
)),
),
},
cdn: true,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: newSRIHash(t, "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711"),
},
{
// parentPluginID (/)
// └── pluginID (/panels/one)
name: "nested plugin deeper than one subfolder should return module hash from parent MANIFEST.txt",
plugin: pluginID,
registry: []*plugins.Plugin{
newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid-nested", "panels", "one"))),
withParent(newPlugin(
parentPluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid-nested"))),
)),
),
},
cdn: true,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: newSRIHash(t, "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f"),
},
{
// grand-parent-app (/)
// ├── parent-datasource (/datasource)
// │ └── child-panel (/datasource/panels/one)
name: "nested plugin of a nested plugin should return module hash from parent MANIFEST.txt",
registry: []*plugins.Plugin{
newPlugin(
"child-panel",
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid-deeply-nested", "datasource", "panels", "one"))),
withParent(newPlugin(
"parent-datasource",
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid-deeply-nested", "datasource"))),
withParent(newPlugin(
"grand-parent-app",
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid-deeply-nested"))),
)),
),
),
),
},
plugin: "child-panel",
cdn: true,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: newSRIHash(t, "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f"),
},
{
name: "nested plugin should not return module hash from parent if it's not registered in the registry",
plugin: pluginID,
registry: []*plugins.Plugin{newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid-nested", "panels", "one"))),
withParent(newPlugin(
parentPluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid-nested"))),
)),
)},
cdn: false,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: "",
},
{
name: "missing module.js entry from MANIFEST.txt should not return module hash",
plugin: pluginID,
registry: []*plugins.Plugin{newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-no-module-js"))),
)},
cdn: false,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: "",
},
{
name: "signed status but missing MANIFEST.txt should not return module hash",
plugin: pluginID,
registry: []*plugins.Plugin{newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-no-manifest-txt"))),
)},
cdn: false,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: "",
},
} {
if tc.name == "" {
var expS string
if tc.expModuleHash == "" {
expS = "should not return module hash"
} else {
expS = "should return module hash"
}
tc.name = fmt.Sprintf("feature=%v, cdn_config=%v %s", tc.features.SriChecksEnabled, tc.cdn, expS)
}
t.Run(tc.name, func(t *testing.T) {
var pluginSettings config.PluginSettings
if tc.cdn {
pluginSettings = config.PluginSettings{
pluginID: {
"cdn": "true",
},
parentPluginID: map[string]string{
"cdn": "true",
},
"grand-parent-app": map[string]string{
"cdn": "true",
},
}
}
features := tc.features
if features == nil {
features = &config.Features{}
}
pCfg := &config.PluginManagementCfg{
PluginsCDNURLTemplate: "http://cdn.example.com",
PluginSettings: pluginSettings,
Features: *features,
}
svc := NewCalculator(
pCfg,
newPluginRegistry(t, tc.registry...),
pluginscdn.ProvideService(pCfg),
signature.ProvideService(pCfg, statickey.New()),
)
mh := svc.ModuleHash(context.Background(), tc.plugin, "")
require.Equal(t, tc.expModuleHash, mh)
})
}
}
func Test_ModuleHash_Cache(t *testing.T) {
pCfg := &config.PluginManagementCfg{
PluginSettings: config.PluginSettings{},
Features: config.Features{SriChecksEnabled: true},
}
svc := NewCalculator(
pCfg,
newPluginRegistry(t),
pluginscdn.ProvideService(pCfg),
signature.ProvideService(pCfg, statickey.New()),
)
const pluginID = "grafana-test-datasource"
t.Run("cache key", func(t *testing.T) {
t.Run("with version", func(t *testing.T) {
const pluginVersion = "1.0.0"
p := newPlugin(pluginID, withInfo(plugins.Info{Version: pluginVersion}))
k := svc.moduleHashCacheKey(p.ID, p.Info.Version)
require.Equal(t, pluginID+":"+pluginVersion, k, "cache key should be correct")
})
t.Run("without version", func(t *testing.T) {
p := newPlugin(pluginID)
k := svc.moduleHashCacheKey(p.ID, p.Info.Version)
require.Equal(t, pluginID+":", k, "cache key should be correct")
})
})
t.Run("ModuleHash usage", func(t *testing.T) {
pV1 := newPlugin(
pluginID,
withInfo(plugins.Info{Version: "1.0.0"}),
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid"))),
)
pCfg = &config.PluginManagementCfg{
PluginsCDNURLTemplate: "https://cdn.grafana.com",
PluginSettings: config.PluginSettings{
pluginID: {
"cdn": "true",
},
},
Features: config.Features{SriChecksEnabled: true},
}
reg := newPluginRegistry(t, pV1)
svc = NewCalculator(
pCfg,
reg,
pluginscdn.ProvideService(pCfg),
signature.ProvideService(pCfg, statickey.New()),
)
k := svc.moduleHashCacheKey(pV1.ID, pV1.Info.Version)
_, ok := svc.moduleHashCache.Load(k)
require.False(t, ok, "cache should initially be empty")
mhV1 := svc.ModuleHash(context.Background(), pV1.ID, pV1.Info.Version)
pV1Exp := newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03")
require.Equal(t, pV1Exp, mhV1, "returned value should be correct")
cachedMh, ok := svc.moduleHashCache.Load(k)
require.True(t, ok)
require.Equal(t, pV1Exp, cachedMh, "cache should contain the returned value")
t.Run("different version uses different cache key", func(t *testing.T) {
pV2 := newPlugin(
pluginID,
withInfo(plugins.Info{Version: "2.0.0"}),
withSignatureStatus(plugins.SignatureStatusValid),
// different fs for different hash
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid-nested"))),
)
err := reg.Add(context.Background(), pV2)
require.NoError(t, err)
mhV2 := svc.ModuleHash(context.Background(), pV2.ID, pV2.Info.Version)
require.NotEqual(t, mhV2, mhV1, "different version should have different hash")
require.Equal(t, newSRIHash(t, "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a"), mhV2)
})
t.Run("cache should be used", func(t *testing.T) {
// edit cache directly
svc.moduleHashCache.Store(k, "hax")
require.Equal(t, "hax", svc.ModuleHash(context.Background(), pV1.ID, pV1.Info.Version))
})
})
}
func TestConvertHashFromSRI(t *testing.T) {
for _, tc := range []struct {
hash string
expHash string
expErr bool
}{
{
hash: "ddfcb449445064e6c39f0c20b15be3cb6a55837cf4781df23d02de005f436811",
expHash: "sha256-3fy0SURQZObDnwwgsVvjy2pVg3z0eB3yPQLeAF9DaBE=",
},
{
hash: "not-a-valid-hash",
expErr: true,
},
} {
t.Run(tc.hash, func(t *testing.T) {
r, err := convertHashForSRI(tc.hash)
if tc.expErr {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tc.expHash, r)
}
})
}
}
func newPlugin(pluginID string, cbs ...func(p *plugins.Plugin) *plugins.Plugin) *plugins.Plugin {
p := &plugins.Plugin{
JSONData: plugins.JSONData{
ID: pluginID,
},
}
for _, cb := range cbs {
p = cb(p)
}
return p
}
func withInfo(info plugins.Info) func(p *plugins.Plugin) *plugins.Plugin {
return func(p *plugins.Plugin) *plugins.Plugin {
p.Info = info
return p
}
}
func withFS(fs plugins.FS) func(p *plugins.Plugin) *plugins.Plugin {
return func(p *plugins.Plugin) *plugins.Plugin {
p.FS = fs
return p
}
}
func withSignatureStatus(status plugins.SignatureStatus) func(p *plugins.Plugin) *plugins.Plugin {
return func(p *plugins.Plugin) *plugins.Plugin {
p.Signature = status
return p
}
}
func withParent(parent *plugins.Plugin) func(p *plugins.Plugin) *plugins.Plugin {
return func(p *plugins.Plugin) *plugins.Plugin {
p.Parent = parent
return p
}
}
func withClass(class plugins.Class) func(p *plugins.Plugin) *plugins.Plugin {
return func(p *plugins.Plugin) *plugins.Plugin {
p.Class = class
return p
}
}
func newSRIHash(t *testing.T, s string) string {
r, err := convertHashForSRI(s)
require.NoError(t, err)
return r
}
type pluginRegistry struct {
registry.Service
reg map[string]*plugins.Plugin
}
func newPluginRegistry(t *testing.T, ps ...*plugins.Plugin) *pluginRegistry {
reg := &pluginRegistry{
reg: make(map[string]*plugins.Plugin),
}
for _, p := range ps {
err := reg.Add(context.Background(), p)
require.NoError(t, err)
}
return reg
}
func (f *pluginRegistry) Plugin(_ context.Context, id, version string) (*plugins.Plugin, bool) {
key := fmt.Sprintf("%s-%s", id, version)
p, exists := f.reg[key]
return p, exists
}
func (f *pluginRegistry) Add(_ context.Context, p *plugins.Plugin) error {
key := fmt.Sprintf("%s-%s", p.ID, p.Info.Version)
f.reg[key] = p
return nil
}

View File

@@ -715,8 +715,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
return nil, err
}
pluginscdnService := pluginscdn.ProvideService(pluginManagementCfg)
calculator := pluginassets2.ProvideModuleHashCalculator(pluginManagementCfg, pluginscdnService, signatureSignature, inMemory)
pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, calculator)
pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, signatureSignature, pluginstoreService)
avatarCacheServer := avatar.ProvideAvatarCacheServer(cfg)
prefService := prefimpl.ProvideService(sqlStore, cfg)
dashboardPermissionsService, err := ossaccesscontrol.ProvideDashboardPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, dashboardService, folderimplService, acimplService, teamService, userService, actionSetService, dashboardServiceImpl, eventualRestConfigProvider)
@@ -1384,8 +1383,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
return nil, err
}
pluginscdnService := pluginscdn.ProvideService(pluginManagementCfg)
calculator := pluginassets2.ProvideModuleHashCalculator(pluginManagementCfg, pluginscdnService, signatureSignature, inMemory)
pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, calculator)
pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, signatureSignature, pluginstoreService)
avatarCacheServer := avatar.ProvideAvatarCacheServer(cfg)
prefService := prefimpl.ProvideService(sqlStore, cfg)
dashboardPermissionsService, err := ossaccesscontrol.ProvideDashboardPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, dashboardService, folderimplService, acimplService, teamService, userService, actionSetService, dashboardServiceImpl, eventualRestConfigProvider)

View File

@@ -2,15 +2,19 @@ package pluginassets
import (
"context"
"encoding/base64"
"encoding/hex"
"fmt"
"path"
"path/filepath"
"sync"
"github.com/Masterminds/semver/v3"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/manager/registry"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/plugins/pluginassets/modulehash"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
)
@@ -24,27 +28,24 @@ var (
scriptLoadingMinSupportedVersion = semver.MustParse(CreatePluginVersionScriptSupportEnabled)
)
func ProvideService(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service,
calc *modulehash.Calculator) *Service {
func ProvideService(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service, sig *signature.Signature, store pluginstore.Store) *Service {
return &Service{
cfg: cfg,
cdn: cdn,
log: log.New("pluginassets"),
calc: calc,
cfg: cfg,
cdn: cdn,
signature: sig,
store: store,
log: log.New("pluginassets"),
}
}
func ProvideModuleHashCalculator(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service,
signature *signature.Signature, reg registry.Service) *modulehash.Calculator {
return modulehash.NewCalculator(cfg, reg, cdn, signature)
}
type Service struct {
cfg *config.PluginManagementCfg
cdn *pluginscdn.Service
calc *modulehash.Calculator
cfg *config.PluginManagementCfg
cdn *pluginscdn.Service
signature *signature.Signature
store pluginstore.Store
log log.Logger
log log.Logger
moduleHashCache sync.Map
}
// LoadingStrategy calculates the loading strategy for a plugin.
@@ -82,8 +83,92 @@ func (s *Service) LoadingStrategy(_ context.Context, p pluginstore.Plugin) plugi
}
// ModuleHash returns the module.js SHA256 hash for a plugin in the format expected by the browser for SRI checks.
// The module hash is read from the plugin's MANIFEST.txt file.
// The plugin can also be a nested plugin.
// If the plugin is unsigned, an empty string is returned.
// The results are cached to avoid repeated reads from the MANIFEST.txt file.
func (s *Service) ModuleHash(ctx context.Context, p pluginstore.Plugin) string {
return s.calc.ModuleHash(ctx, p.ID, p.Info.Version)
k := s.moduleHashCacheKey(p)
cachedValue, ok := s.moduleHashCache.Load(k)
if ok {
return cachedValue.(string)
}
mh, err := s.moduleHash(ctx, p, "")
if err != nil {
s.log.Error("Failed to calculate module hash", "plugin", p.ID, "error", err)
}
s.moduleHashCache.Store(k, mh)
return mh
}
// moduleHash is the underlying function for ModuleHash. See its documentation for more information.
// If the plugin is not a CDN plugin, the function will return an empty string.
// It will read the module hash from the MANIFEST.txt in the [[plugins.FS]] of the provided plugin.
// If childFSBase is provided, the function will try to get the hash from MANIFEST.txt for the provided children's
// module.js file, rather than for the provided plugin.
func (s *Service) moduleHash(ctx context.Context, p pluginstore.Plugin, childFSBase string) (r string, err error) {
if !s.cfg.Features.SriChecksEnabled {
return "", nil
}
// Ignore unsigned plugins
if !p.Signature.IsValid() {
return "", nil
}
if p.Parent != nil {
// Nested plugin
parent, ok := s.store.Plugin(ctx, p.Parent.ID)
if !ok {
return "", fmt.Errorf("parent plugin plugin %q for child plugin %q not found", p.Parent.ID, p.ID)
}
// The module hash is contained within the parent's MANIFEST.txt file.
// For example, the parent's MANIFEST.txt will contain an entry similar to this:
//
// ```
// "datasource/module.js": "1234567890abcdef..."
// ```
//
// Recursively call moduleHash with the parent plugin and with the children plugin folder path
// to get the correct module hash for the nested plugin.
if childFSBase == "" {
childFSBase = p.Base()
}
return s.moduleHash(ctx, parent, childFSBase)
}
// Only CDN plugins are supported for SRI checks.
// CDN plugins have the version as part of the URL, which acts as a cache-buster.
// Needed due to: https://github.com/grafana/plugin-tools/pull/1426
// FS plugins build before this change will have SRI mismatch issues.
if !s.cdnEnabled(p.ID, p.FS) {
return "", nil
}
manifest, err := s.signature.ReadPluginManifestFromFS(ctx, p.FS)
if err != nil {
return "", fmt.Errorf("read plugin manifest: %w", err)
}
if !manifest.IsV2() {
return "", nil
}
var childPath string
if childFSBase != "" {
// Calculate the relative path of the child plugin folder from the parent plugin folder.
childPath, err = p.FS.Rel(childFSBase)
if err != nil {
return "", fmt.Errorf("rel path: %w", err)
}
// MANIFETS.txt uses forward slashes as path separators.
childPath = filepath.ToSlash(childPath)
}
moduleHash, ok := manifest.Files[path.Join(childPath, "module.js")]
if !ok {
return "", nil
}
return convertHashForSRI(moduleHash)
}
func (s *Service) compatibleCreatePluginVersion(ps map[string]string) bool {
@@ -103,3 +188,17 @@ func (s *Service) compatibleCreatePluginVersion(ps map[string]string) bool {
func (s *Service) cdnEnabled(pluginID string, fs plugins.FS) bool {
return s.cdn.PluginSupported(pluginID) || fs.Type().CDN()
}
// convertHashForSRI takes a SHA256 hash string and returns it as expected by the browser for SRI checks.
func convertHashForSRI(h string) (string, error) {
hb, err := hex.DecodeString(h)
if err != nil {
return "", fmt.Errorf("hex decode string: %w", err)
}
return "sha256-" + base64.StdEncoding.EncodeToString(hb), nil
}
// moduleHashCacheKey returns a unique key for the module hash cache.
func (s *Service) moduleHashCacheKey(p pluginstore.Plugin) string {
return p.ID + ":" + p.Info.Version
}

View File

@@ -2,14 +2,19 @@ package pluginassets
import (
"context"
"fmt"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/manager/pluginfakes"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
)
@@ -174,6 +179,349 @@ func TestService_Calculate(t *testing.T) {
}
}
func TestService_ModuleHash(t *testing.T) {
const (
pluginID = "grafana-test-datasource"
parentPluginID = "grafana-test-app"
)
for _, tc := range []struct {
name string
features *config.Features
store []pluginstore.Plugin
// Can be used to configure plugin's fs
// fs cdn type = loaded from CDN with no files on disk
// fs local type = files on disk but served from CDN only if cdn=true
plugin pluginstore.Plugin
// When true, set cdn=true in config
cdn bool
expModuleHash string
}{
{
name: "unsigned should not return module hash",
plugin: newPlugin(pluginID, withSignatureStatus(plugins.SignatureStatusUnsigned)),
cdn: false,
features: &config.Features{SriChecksEnabled: false},
expModuleHash: "",
},
{
plugin: newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
withClass(plugins.ClassExternal),
),
cdn: true,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"),
},
{
plugin: newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
withClass(plugins.ClassExternal),
),
cdn: true,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"),
},
{
plugin: newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
),
cdn: false,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: "",
},
{
plugin: newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
),
cdn: true,
features: &config.Features{SriChecksEnabled: false},
expModuleHash: "",
},
{
plugin: newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
),
cdn: false,
features: &config.Features{SriChecksEnabled: false},
expModuleHash: "",
},
{
// parentPluginID (/)
// └── pluginID (/datasource)
name: "nested plugin should return module hash from parent MANIFEST.txt",
store: []pluginstore.Plugin{
newPlugin(
parentPluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested"))),
),
},
plugin: newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "datasource"))),
withParent(parentPluginID),
),
cdn: true,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: newSRIHash(t, "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711"),
},
{
// parentPluginID (/)
// └── pluginID (/panels/one)
name: "nested plugin deeper than one subfolder should return module hash from parent MANIFEST.txt",
store: []pluginstore.Plugin{
newPlugin(
parentPluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested"))),
),
},
plugin: newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "panels", "one"))),
withParent(parentPluginID),
),
cdn: true,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: newSRIHash(t, "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f"),
},
{
// grand-parent-app (/)
// ├── parent-datasource (/datasource)
// │ └── child-panel (/datasource/panels/one)
name: "nested plugin of a nested plugin should return module hash from parent MANIFEST.txt",
store: []pluginstore.Plugin{
newPlugin(
"grand-parent-app",
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested"))),
),
newPlugin(
"parent-datasource",
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource"))),
withParent("grand-parent-app"),
),
},
plugin: newPlugin(
"child-panel",
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource", "panels", "one"))),
withParent("parent-datasource"),
),
cdn: true,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: newSRIHash(t, "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f"),
},
{
name: "nested plugin should not return module hash from parent if it's not registered in the store",
store: []pluginstore.Plugin{},
plugin: newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "panels", "one"))),
withParent(parentPluginID),
),
cdn: false,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: "",
},
{
name: "missing module.js entry from MANIFEST.txt should not return module hash",
plugin: newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-no-module-js"))),
),
cdn: false,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: "",
},
{
name: "signed status but missing MANIFEST.txt should not return module hash",
plugin: newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-no-manifest-txt"))),
),
cdn: false,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: "",
},
} {
if tc.name == "" {
var expS string
if tc.expModuleHash == "" {
expS = "should not return module hash"
} else {
expS = "should return module hash"
}
tc.name = fmt.Sprintf("feature=%v, cdn_config=%v, class=%v %s", tc.features.SriChecksEnabled, tc.cdn, tc.plugin.Class, expS)
}
t.Run(tc.name, func(t *testing.T) {
var pluginSettings config.PluginSettings
if tc.cdn {
pluginSettings = config.PluginSettings{
pluginID: {
"cdn": "true",
},
parentPluginID: map[string]string{
"cdn": "true",
},
"grand-parent-app": map[string]string{
"cdn": "true",
},
}
}
features := tc.features
if features == nil {
features = &config.Features{}
}
pCfg := &config.PluginManagementCfg{
PluginsCDNURLTemplate: "http://cdn.example.com",
PluginSettings: pluginSettings,
Features: *features,
}
svc := ProvideService(
pCfg,
pluginscdn.ProvideService(pCfg),
signature.ProvideService(pCfg, statickey.New()),
pluginstore.NewFakePluginStore(tc.store...),
)
mh := svc.ModuleHash(context.Background(), tc.plugin)
require.Equal(t, tc.expModuleHash, mh)
})
}
}
func TestService_ModuleHash_Cache(t *testing.T) {
pCfg := &config.PluginManagementCfg{
PluginSettings: config.PluginSettings{},
Features: config.Features{SriChecksEnabled: true},
}
svc := ProvideService(
pCfg,
pluginscdn.ProvideService(pCfg),
signature.ProvideService(pCfg, statickey.New()),
pluginstore.NewFakePluginStore(),
)
const pluginID = "grafana-test-datasource"
t.Run("cache key", func(t *testing.T) {
t.Run("with version", func(t *testing.T) {
const pluginVersion = "1.0.0"
p := newPlugin(pluginID, withInfo(plugins.Info{Version: pluginVersion}))
k := svc.moduleHashCacheKey(p)
require.Equal(t, pluginID+":"+pluginVersion, k, "cache key should be correct")
})
t.Run("without version", func(t *testing.T) {
p := newPlugin(pluginID)
k := svc.moduleHashCacheKey(p)
require.Equal(t, pluginID+":", k, "cache key should be correct")
})
})
t.Run("ModuleHash usage", func(t *testing.T) {
pV1 := newPlugin(
pluginID,
withInfo(plugins.Info{Version: "1.0.0"}),
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
)
pCfg = &config.PluginManagementCfg{
PluginsCDNURLTemplate: "https://cdn.grafana.com",
PluginSettings: config.PluginSettings{
pluginID: {
"cdn": "true",
},
},
Features: config.Features{SriChecksEnabled: true},
}
svc = ProvideService(
pCfg,
pluginscdn.ProvideService(pCfg),
signature.ProvideService(pCfg, statickey.New()),
pluginstore.NewFakePluginStore(),
)
k := svc.moduleHashCacheKey(pV1)
_, ok := svc.moduleHashCache.Load(k)
require.False(t, ok, "cache should initially be empty")
mhV1 := svc.ModuleHash(context.Background(), pV1)
pV1Exp := newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03")
require.Equal(t, pV1Exp, mhV1, "returned value should be correct")
cachedMh, ok := svc.moduleHashCache.Load(k)
require.True(t, ok)
require.Equal(t, pV1Exp, cachedMh, "cache should contain the returned value")
t.Run("different version uses different cache key", func(t *testing.T) {
pV2 := newPlugin(
pluginID,
withInfo(plugins.Info{Version: "2.0.0"}),
withSignatureStatus(plugins.SignatureStatusValid),
// different fs for different hash
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested"))),
)
mhV2 := svc.ModuleHash(context.Background(), pV2)
require.NotEqual(t, mhV2, mhV1, "different version should have different hash")
require.Equal(t, newSRIHash(t, "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a"), mhV2)
})
t.Run("cache should be used", func(t *testing.T) {
// edit cache directly
svc.moduleHashCache.Store(k, "hax")
require.Equal(t, "hax", svc.ModuleHash(context.Background(), pV1))
})
})
}
func TestConvertHashFromSRI(t *testing.T) {
for _, tc := range []struct {
hash string
expHash string
expErr bool
}{
{
hash: "ddfcb449445064e6c39f0c20b15be3cb6a55837cf4781df23d02de005f436811",
expHash: "sha256-3fy0SURQZObDnwwgsVvjy2pVg3z0eB3yPQLeAF9DaBE=",
},
{
hash: "not-a-valid-hash",
expErr: true,
},
} {
t.Run(tc.hash, func(t *testing.T) {
r, err := convertHashForSRI(tc.hash)
if tc.expErr {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tc.expHash, r)
}
})
}
}
func newPlugin(pluginID string, cbs ...func(p pluginstore.Plugin) pluginstore.Plugin) pluginstore.Plugin {
p := pluginstore.Plugin{
JSONData: plugins.JSONData{
@@ -186,6 +534,13 @@ func newPlugin(pluginID string, cbs ...func(p pluginstore.Plugin) pluginstore.Pl
return p
}
func withInfo(info plugins.Info) func(p pluginstore.Plugin) pluginstore.Plugin {
return func(p pluginstore.Plugin) pluginstore.Plugin {
p.Info = info
return p
}
}
func withFS(fs plugins.FS) func(p pluginstore.Plugin) pluginstore.Plugin {
return func(p pluginstore.Plugin) pluginstore.Plugin {
p.FS = fs
@@ -193,6 +548,13 @@ func withFS(fs plugins.FS) func(p pluginstore.Plugin) pluginstore.Plugin {
}
}
func withSignatureStatus(status plugins.SignatureStatus) func(p pluginstore.Plugin) pluginstore.Plugin {
return func(p pluginstore.Plugin) pluginstore.Plugin {
p.Signature = status
return p
}
}
func withAngular(angular bool) func(p pluginstore.Plugin) pluginstore.Plugin {
return func(p pluginstore.Plugin) pluginstore.Plugin {
p.Angular = plugins.AngularMeta{Detected: angular}
@@ -200,6 +562,13 @@ func withAngular(angular bool) func(p pluginstore.Plugin) pluginstore.Plugin {
}
}
func withParent(parentID string) func(p pluginstore.Plugin) pluginstore.Plugin {
return func(p pluginstore.Plugin) pluginstore.Plugin {
p.Parent = &pluginstore.ParentPlugin{ID: parentID}
return p
}
}
func withClass(class plugins.Class) func(p pluginstore.Plugin) pluginstore.Plugin {
return func(p pluginstore.Plugin) pluginstore.Plugin {
p.Class = class
@@ -218,3 +587,9 @@ func newPluginSettings(pluginID string, kv map[string]string) config.PluginSetti
pluginID: kv,
}
}
func newSRIHash(t *testing.T, s string) string {
r, err := convertHashForSRI(s)
require.NoError(t, err)
return r
}

View File

@@ -131,7 +131,6 @@ var WireSet = wire.NewSet(
plugincontext.ProvideBaseService,
wire.Bind(new(plugincontext.BasePluginContextProvider), new(*plugincontext.BaseProvider)),
plugininstaller.ProvideService,
pluginassets.ProvideModuleHashCalculator,
pluginassets.ProvideService,
pluginchecker.ProvidePreinstall,
wire.Bind(new(pluginchecker.Preinstall), new(*pluginchecker.PreinstallImpl)),

View File

@@ -109,7 +109,7 @@ const defaultMatchers = {
* "Time as X" core component, expects ascending x
*/
export class GraphNG extends Component<GraphNGProps, GraphNGState> {
private plotInstance: React.RefObject<uPlot>;
private plotInstance: React.RefObject<uPlot | null>;
constructor(props: GraphNGProps) {
super(props);

View File

@@ -33,7 +33,7 @@ global.ResizeObserver = jest.fn().mockImplementation((callback) => {
});
// Helper function to assign a mock div to a ref
function assignMockDivToRef(ref: React.RefObject<HTMLDivElement>, mockDiv: HTMLDivElement) {
function assignMockDivToRef(ref: React.RefObject<HTMLDivElement | null>, mockDiv: HTMLDivElement) {
// Use type assertion to bypass readonly restriction in tests
(ref as { current: HTMLDivElement | null }).current = mockDiv;
}

View File

@@ -8,7 +8,7 @@ import grafanaTextLogoDarkSvg from 'img/grafana_text_logo_dark.svg';
import grafanaTextLogoLightSvg from 'img/grafana_text_logo_light.svg';
interface SoloPanelPageLogoProps {
containerRef: React.RefObject<HTMLDivElement>;
containerRef: React.RefObject<HTMLDivElement | null>;
isHovered: boolean;
hideLogo?: UrlQueryValue;
}

View File

@@ -61,7 +61,7 @@ interface State {
class UnThemedTransformationsEditor extends React.PureComponent<TransformationsEditorProps, State> {
subscription?: Unsubscribable;
ref: RefObject<HTMLDivElement>;
ref: RefObject<HTMLDivElement | null>;
constructor(props: TransformationsEditorProps) {
super(props);

View File

@@ -43,7 +43,7 @@ export const LibraryPanelsView = ({
}
);
const asyncDispatch = useMemo(() => asyncDispatcher(dispatch), [dispatch]);
const abortControllerRef = useRef<AbortController>();
const abortControllerRef = useRef<AbortController | null>(null);
useDebounce(
() => {

View File

@@ -12,7 +12,7 @@ export interface Props {}
export const LiveConnectionWarning = memo(function LiveConnectionWarning() {
const [show, setShow] = useState<boolean | undefined>(undefined);
const subscriptionRef = useRef<Unsubscribable>();
const subscriptionRef = useRef<Unsubscribable | null>(null);
const styles = useStyles2(getStyle);
useEffect(() => {

View File

@@ -38,7 +38,7 @@ export function useSearchKeyboardNavigation(
): ItemSelection {
const highlightIndexRef = useRef<ItemSelection>({ x: 0, y: -1 });
const [highlightIndex, setHighlightIndex] = useState<ItemSelection>({ x: 0, y: -1 });
const urlsRef = useRef<Field>();
const urlsRef = useRef<Field | null>(null);
// Clear selection when the search results change
useEffect(() => {

View File

@@ -77,7 +77,7 @@ export const SuggestionsInput = ({
const theme = useTheme2();
const styles = getStyles(theme, inputHeight);
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>();
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
useEffect(() => {
scrollRef.current?.scrollTo(0, scrollTop);

View File

@@ -11,7 +11,7 @@ import checkboxWhitePng from 'img/checkbox_white.png';
import { ALL_VARIABLE_VALUE } from '../../constants';
export interface Props extends React.HTMLProps<HTMLUListElement>, Themeable2 {
export interface Props extends Omit<React.HTMLProps<HTMLUListElement>, 'onToggle'>, Themeable2 {
multi: boolean;
values: VariableOption[];
selectedValues: VariableOption[];

View File

@@ -20,8 +20,8 @@ interface CodeEditorProps {
export const LogsQLCodeEditor = (props: CodeEditorProps) => {
const { query, datasource, onChange } = props;
const monacoRef = useRef<Monaco>();
const disposalRef = useRef<monacoType.IDisposable>();
const monacoRef = useRef<Monaco | null>(null);
const disposalRef = useRef<monacoType.IDisposable | undefined>(undefined);
const onFocus = useCallback(async () => {
disposalRef.current = await reRegisterCompletionProvider(

View File

@@ -42,8 +42,8 @@ interface LogsCodeEditorProps {
export const PPLQueryEditor = (props: LogsCodeEditorProps) => {
const { query, datasource, onChange } = props;
const monacoRef = useRef<Monaco>();
const disposalRef = useRef<monacoType.IDisposable>();
const monacoRef = useRef<Monaco | null>(null);
const disposalRef = useRef<monacoType.IDisposable | undefined>(undefined);
const onFocus = useCallback(async () => {
disposalRef.current = await reRegisterCompletionProvider(

View File

@@ -20,8 +20,8 @@ interface SQLCodeEditorProps {
export const SQLQueryEditor = (props: SQLCodeEditorProps) => {
const { query, datasource, onChange } = props;
const monacoRef = useRef<Monaco>();
const disposalRef = useRef<monacoType.IDisposable>();
const monacoRef = useRef<Monaco | null>(null);
const disposalRef = useRef<monacoType.IDisposable | undefined>(undefined);
const onFocus = useCallback(async () => {
disposalRef.current = await reRegisterCompletionProvider(

View File

@@ -94,7 +94,7 @@ const EDITOR_HEIGHT_OFFSET = 2;
* Hook that returns function that will set up monaco autocomplete for the label selector
*/
function useAutocomplete(getLabelValues: (label: string) => Promise<string[]>, labels?: string[]) {
const providerRef = useRef<CompletionProvider>();
const providerRef = useRef<CompletionProvider | null>(null);
if (providerRef.current === undefined) {
providerRef.current = new CompletionProvider();
}

View File

@@ -126,7 +126,7 @@ export function LokiContextUi(props: LokiContextUiProps) {
window.localStorage.getItem(SHOULD_INCLUDE_PIPELINE_OPERATIONS) === 'true'
);
const timerHandle = useRef<number>();
const timerHandle = useRef<number | null>(null);
const previousInitialized = useRef<boolean>(false);
const previousContextFilters = useRef<ContextFilter[]>([]);
@@ -191,14 +191,18 @@ export function LokiContextUi(props: LokiContextUiProps) {
}, 1500);
return () => {
clearTimeout(timerHandle.current);
if (timerHandle.current) {
clearTimeout(timerHandle.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contextFilters, initialized]);
useEffect(() => {
return () => {
clearTimeout(timerHandle.current);
if (timerHandle.current) {
clearTimeout(timerHandle.current);
}
onClose();
};
}, [onClose]);

View File

@@ -52,7 +52,7 @@ export function TraceQLEditor(props: Props) {
const onRunQueryRef = useRef(onRunQuery);
onRunQueryRef.current = onRunQuery;
const errorTimeoutId = useRef<number>();
const errorTimeoutId = useRef<number | null>(null);
return (
<>
@@ -103,7 +103,9 @@ export function TraceQLEditor(props: Props) {
}
// Remove previous callback if existing, to prevent squiggles from been shown while the user is still typing
window.clearTimeout(errorTimeoutId.current);
if (errorTimeoutId.current) {
window.clearTimeout(errorTimeoutId.current);
}
const errorNodes = getErrorNodes(model.getValue());
const cursorPosition = changeEvent.changes[0].rangeOffset;

View File

@@ -1,5 +1,5 @@
export function renderHistogram(
can: React.RefObject<HTMLCanvasElement>,
can: React.RefObject<HTMLCanvasElement | null>,
histCanWidth: number,
histCanHeight: number,
xVals: number[],

View File

@@ -196,7 +196,7 @@ export const LogsPanel = ({
const dataSourcesMap = useDatasourcesFromTargets(panelData.request?.targets);
// Prevents the scroll position to change when new data from infinite scrolling is received
const keepScrollPositionRef = useRef<null | 'infinite-scroll' | 'user'>(null);
let closeCallback = useRef<() => void>();
const closeCallback = useRef<(() => void) | null>(null);
const { app, eventBus, onAddAdHocFilter } = usePanelContext();
useEffect(() => {

View File

@@ -70,7 +70,7 @@ export function useLayout(
const currentSignature = createDataSignature(rawNodes, rawEdges);
const isMounted = useMountedState();
const layoutWorkerCancelRef = useRef<(() => void) | undefined>();
const layoutWorkerCancelRef = useRef<(() => void) | null>(null);
useUnmount(() => {
if (layoutWorkerCancelRef.current) {

View File

@@ -133,7 +133,7 @@ export const AnnotationsPlugin2 = ({
const newRangeRef = useRef(newRange);
newRangeRef.current = newRange;
const xAxisRef = useRef<HTMLDivElement>();
const xAxisRef = useRef<HTMLDivElement | null>(null);
useLayoutEffect(() => {
config.addHook('ready', (u) => {

View File

@@ -23,7 +23,7 @@ export const ExemplarsPlugin = ({
maxHeight,
maxWidth,
}: ExemplarsPluginProps) => {
const plotInstance = useRef<uPlot>();
const plotInstance = useRef<uPlot | null>(null);
const [lockedExemplarRowIndex, setLockedExemplarRowIndex] = useState<number | undefined>();

View File

@@ -11,7 +11,7 @@ interface ThresholdControlsPluginProps {
}
export const OutsideRangePlugin = ({ config, onChangeTimeRange }: ThresholdControlsPluginProps) => {
const plotInstance = useRef<uPlot>();
const plotInstance = useRef<uPlot | null>(null);
const [timevalues, setTimeValues] = useState<number[] | TypedArray>([]);
const [timeRange, setTimeRange] = useState<Scale | undefined>();

View File

@@ -16,7 +16,7 @@ interface ThresholdControlsPluginProps {
}
export const ThresholdControlsPlugin = ({ config, fieldConfig, onThresholdsChange }: ThresholdControlsPluginProps) => {
const plotInstance = useRef<uPlot>();
const plotInstance = useRef<uPlot | null>(null);
const [renderToken, setRenderToken] = useState(0);
useLayoutEffect(() => {