Compare commits

..

18 Commits

Author SHA1 Message Date
Hugo Häggmark 6024fbb363 Merge remote-tracking branch 'origin/main' into hugoh/decouple-app-plugins 2025-12-15 10:23:39 +01:00
Hugo Häggmark 512f4bc8dc chore: more refactor 2025-12-15 10:13:26 +01:00
Gonzalo Trigueros Manzanas 0c49337205 Provisioning: add warning column to JobSummary UI. (#115220) 2025-12-15 08:22:33 +00:00
grafana-pr-automation[bot] c5345498b1 I18n: Download translations from Crowdin (#115291)
New Crowdin translations by GitHub Action

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-13 00:42:48 +00:00
Isabel Matwawana 1bcccd5e61 Docs: Update export as JSON task (#115288) 2025-12-12 22:26:28 +00:00
Oscar Kilhed 12b38d1b7a Dashboards: Never allow rows with hidden header to be collapsed (#115284)
Never allow rows with hidden header to be collapsed
2025-12-12 22:14:48 +00:00
Paul Marbach 359d097154 Table: Remove hardcoded assumption of __nestedFrames field name (#115117)
* Table: Remove hardcoded assumption of __nestedFrames field name

* E2E for nested tables

* Apply suggestion from @fastfrwrd
2025-12-12 21:57:47 +00:00
Haris Rozajac cfc5d96c34 Dashboard Schema V2: Fix panel query tab (#115276)
fix panel query tab for v2 schema
2025-12-12 14:39:43 -07:00
Hugo Häggmark 88924ee9ac Merge remote-tracking branch 'origin/main' into hugoh/decouple-app-plugins 2025-12-12 13:55:00 +01:00
Hugo Häggmark 5e3c7ad0c1 chore: refactoring to async 2025-12-12 12:10:28 +01:00
Hugo Häggmark 75e08a20f6 chore: move exports to unstable 2025-12-10 14:33:54 +01:00
Hugo Häggmark c8908c5100 chore: remove console again 2025-12-08 06:55:10 +01:00
Hugo Häggmark d8106adb63 chore: add back console.error 2025-12-08 06:08:59 +01:00
Hugo Häggmark a4c1b51182 chore: updates after pr feedback 2025-12-08 06:08:59 +01:00
Hugo Häggmark 535c9be2f7 chore: remove console error for now 2025-12-08 06:08:58 +01:00
Hugo Häggmark 49f891a24d chore: updates after pr feedback 2025-12-08 06:08:58 +01:00
Hugo Häggmark 86018141d0 chore: updates after pr feedback 2025-12-08 06:08:57 +01:00
Hugo Häggmark 7fd2476a12 bootdata: decouples config.apps 2025-12-08 06:08:57 +01:00
80 changed files with 3606 additions and 3118 deletions
+1
View File
@@ -653,6 +653,7 @@ i18next.config.ts @grafana/grafana-frontend-platform
/packages/grafana-runtime/src/components/QueryEditorWithMigration* @grafana/plugins-platform-frontend @grafana/plugins-platform-backend
/packages/grafana-runtime/src/config.ts @grafana/grafana-frontend-platform
/packages/grafana-runtime/src/services/ @grafana/grafana-frontend-platform
/packages/grafana-runtime/src/services/plugins.ts @grafana/plugins-platform-frontend
/packages/grafana-runtime/src/services/pluginExtensions @grafana/plugins-platform-frontend
/packages/grafana-runtime/src/services/CorrelationsService.ts @grafana/datapro
/packages/grafana-runtime/src/services/LocationService.test.tsx @grafana/grafana-search-navigate-organise
@@ -60,7 +60,6 @@ The following documents will help you get started with the PostgreSQL data sourc
- [Configure the PostgreSQL data source](ref:configure-postgres-data-source)
- [PostgreSQL query editor](ref:postgres-query-editor)
- [Troubleshooting](troubleshooting/)
After you have configured the data source you can:
@@ -1,380 +0,0 @@
---
aliases:
- ../../data-sources/postgres/troubleshooting/
description: Troubleshooting the PostgreSQL data source in Grafana
keywords:
- grafana
- postgresql
- troubleshooting
- errors
labels:
products:
- cloud
- enterprise
- oss
menuTitle: Troubleshooting
title: Troubleshoot PostgreSQL data source issues
weight: 600
---
# Troubleshoot PostgreSQL data source issues
This document provides troubleshooting information for common errors you may encounter when using the PostgreSQL data source in Grafana.
## Connection errors
The following errors occur when Grafana cannot establish or maintain a connection to PostgreSQL.
### Failed to connect to PostgreSQL
**Error message:** `failed to connect to ... : connect: connection refused` or `dial tcp: connect: connection refused`
**Cause:** Grafana cannot establish a network connection to the PostgreSQL server.
**Solution:**
1. Verify that the Host URL is correct in the data source configuration.
1. Check that PostgreSQL is running and accessible from the Grafana server.
1. Verify the port is correct (the PostgreSQL default port is `5432`).
1. Ensure there are no firewall rules blocking the connection.
1. Check that PostgreSQL is configured to accept connections from the Grafana server in `pg_hba.conf`.
1. For Grafana Cloud, ensure you have configured [Private data source connect](https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/) if your PostgreSQL instance is not publicly accessible.
### Request timed out
**Error message:** "context deadline exceeded" or "i/o timeout"
**Cause:** The connection to PostgreSQL timed out before receiving a response.
**Solution:**
1. Check the network latency between Grafana and PostgreSQL.
1. Verify that PostgreSQL is not overloaded or experiencing performance issues.
1. Increase the **Max lifetime** setting in the data source configuration under **Connection limits**.
1. Reduce the time range or complexity of your query.
1. Check if any network devices (load balancers, proxies) are timing out the connection.
### Host not found
**Error message:** `failed to connect to ... : hostname resolving error` or `lookup hostname: no such host`
**Cause:** The hostname specified in the data source configuration cannot be resolved.
**Solution:**
1. Verify the hostname is spelled correctly.
1. Check that DNS resolution is working on the Grafana server.
1. Try using an IP address instead of a hostname.
1. Ensure the PostgreSQL server is accessible from the Grafana server's network.
## Authentication errors
The following errors occur when there are issues with authentication credentials or permissions.
### Password authentication failed
**Error message:** `failed to connect to ... : server error: FATAL: password authentication failed for user "username" (SQLSTATE 28P01)`
**Cause:** The username or password is incorrect.
**Solution:**
1. Verify that the username and password are correct in the data source configuration.
1. Check that the user exists in PostgreSQL.
1. Verify the password has not expired.
1. If no password is specified, ensure a [PostgreSQL password file](https://www.postgresql.org/docs/current/static/libpq-pgpass.html) is configured.
### Permission denied
**Error message:** `ERROR: permission denied for table table_name (SQLSTATE 42501)` or `ERROR: permission denied for schema schema_name (SQLSTATE 42501)`
**Cause:** The database user does not have permission to access the requested table or schema.
**Solution:**
1. Verify the user has `SELECT` permissions on the required tables.
1. Grant the necessary permissions:
```sql
GRANT USAGE ON SCHEMA schema_name TO grafanareader;
GRANT SELECT ON schema_name.table_name TO grafanareader;
```
1. Check that the user has access to the correct database.
1. Verify the search path includes the schema containing your tables.
### No pg_hba.conf entry
**Error message:** `failed to connect to ... : server error: FATAL: no pg_hba.conf entry for host "ip_address", user "username", database "database_name" (SQLSTATE 28000)`
**Cause:** PostgreSQL is not configured to accept connections from the Grafana server.
**Solution:**
1. Edit the `pg_hba.conf` file on the PostgreSQL server.
1. Add an entry to allow connections from the Grafana server:
```text
host database_name username grafana_ip/32 md5
```
1. Reload PostgreSQL configuration: `SELECT pg_reload_conf();`
1. If using SSL, ensure the correct authentication method is specified (for example, `hostssl` instead of `host`).
## TLS and certificate errors
The following errors occur when there are issues with TLS configuration.
### Certificate verification failed
**Error message:** "x509: certificate signed by unknown authority" or "certificate verify failed"
**Cause:** Grafana cannot verify the TLS certificate presented by PostgreSQL.
**Solution:**
1. Set the **TLS/SSL Mode** to the appropriate level (`require`, `verify-ca`, or `verify-full`).
1. If using a self-signed certificate, add the CA certificate in **TLS/SSL Auth Details**.
1. Verify the certificate chain is complete and valid.
1. Ensure the certificate has not expired.
1. For testing only, set **TLS/SSL Mode** to `disable` (not recommended for production).
### SSL not supported
**Error message:** `failed to connect to ... : server refused TLS connection` or `server does not support SSL`
**Cause:** The PostgreSQL server is not configured for SSL connections, but the data source requires SSL.
**Solution:**
1. Set **TLS/SSL Mode** to `disable` if SSL is not required.
1. Alternatively, enable SSL on the PostgreSQL server by configuring `ssl = on` in `postgresql.conf`.
1. Ensure the server has valid SSL certificates configured.
### Client certificate error
**Error message:** "TLS: failed to find any PEM data in certificate input" or "could not load client certificate"
**Cause:** The client certificate or key is invalid or incorrectly formatted.
**Solution:**
1. Verify the certificate and key are in PEM format.
1. Ensure the certificate file path is correct and readable by the Grafana process.
1. Check that the certificate and key match (belong to the same key pair).
1. If using certificate content, ensure you've pasted the complete certificate including headers.
## Database errors
The following errors occur when there are issues with the database configuration.
### Database does not exist
**Error message:** `failed to connect to ... : server error: FATAL: database "database_name" does not exist (SQLSTATE 3D000)`
**Cause:** The specified database name is incorrect or the database doesn't exist.
**Solution:**
1. Verify the database name in the data source configuration.
1. Check that the database exists: `\l` in psql or `SELECT datname FROM pg_database;`
1. Ensure the database name is case-sensitive and matches exactly.
1. Verify the user has permission to connect to the database.
### Relation does not exist
**Error message:** `ERROR: relation "table_name" does not exist (SQLSTATE 42P01)`
**Cause:** The specified table or view does not exist, or the user cannot access it.
**Solution:**
1. Verify the table name is correct and exists in the database.
1. Check the schema name if the table is not in the public schema.
1. Use fully qualified names: `schema_name.table_name`.
1. Verify the user has `SELECT` permission on the table.
1. Check the search path: `SHOW search_path;`
## Query errors
The following errors occur when there are issues with SQL syntax or query execution.
### Query syntax error
**Error message:** `ERROR: syntax error at or near "keyword" (SQLSTATE 42601)`
**Cause:** The SQL query contains invalid syntax.
**Solution:**
1. Check your query syntax for typos or invalid keywords.
1. Verify column and table names are correctly quoted if they contain special characters or are reserved words.
1. Use double quotes for identifiers: `"column_name"`.
1. Test the query directly in a PostgreSQL client (psql, pgAdmin).
### Column does not exist
**Error message:** `ERROR: column "column_name" does not exist (SQLSTATE 42703)`
**Cause:** The specified column name is incorrect or doesn't exist in the table.
**Solution:**
1. Verify the column name is spelled correctly.
1. Check that column names are case-sensitive in PostgreSQL when quoted.
1. Use the correct quoting for column names: `"Column_Name"` for case-sensitive names.
1. Verify the column exists in the table: `\d table_name` in psql.
### No time column found
**Error message:** "no time column found" or time series visualization shows no data
**Cause:** The query result does not include a properly formatted time column.
**Solution:**
1. Ensure your query includes a column named `time` that returns a timestamp or epoch value.
1. Use an alias to rename your time column: `SELECT created_at AS time`.
1. Ensure the time column is of type `timestamp`, `timestamptz`, or a numeric epoch value.
1. Order results by the time column: `ORDER BY time ASC`.
### Macro expansion error
**Error message:** "macro '$\_\_timeFilter' not found" or incorrect query results with macros
**Cause:** Grafana macros are not being properly expanded.
**Solution:**
1. Verify the macro syntax is correct, for example `$__timeFilter(time_column)`.
1. Ensure the column name passed to the macro exists in your table.
1. Use the **Preview** toggle in Builder mode to see the expanded query.
1. For time-based macros, ensure the column contains timestamp data.
## Performance issues
The following issues relate to slow query execution or resource constraints.
### Query timeout
**Error message:** "canceling statement due to statement timeout" or "query timeout"
**Cause:** The query took longer than the configured timeout.
**Solution:**
1. Reduce the time range of your query.
1. Add indexes to columns used in WHERE clauses and joins.
1. Use the `$__timeFilter` macro to limit data to the dashboard time range.
1. Increase the statement timeout in PostgreSQL if you have admin access.
1. Optimize your query to reduce complexity.
### Too many connections
**Error message:** `failed to connect to ... : server error: FATAL: too many connections for role "username" (SQLSTATE 53300)` or `connection pool exhausted`
**Cause:** The maximum number of connections to PostgreSQL has been reached.
**Solution:**
1. Reduce the **Max open** connections setting in the data source configuration.
1. Increase `max_connections` in PostgreSQL's `postgresql.conf` if you have admin access.
1. Check for connection leaks in other applications connecting to the same database.
1. Enable **Auto max idle** to automatically manage idle connections.
### Slow query performance
**Cause:** Queries take a long time to execute.
**Solution:**
1. Reduce the time range of your query.
1. Add appropriate indexes to your tables.
1. Use the `$__timeFilter` macro to limit the data scanned.
1. Increase the **Min time interval** setting to reduce the number of data points.
1. Use `EXPLAIN ANALYZE` in PostgreSQL to identify query bottlenecks.
1. Consider using materialized views for complex aggregations.
## Provisioning errors
The following errors occur when provisioning the data source via YAML.
### Invalid provisioning configuration
**Error message:** "metric request error" or data source test fails after provisioning
**Cause:** The provisioning YAML file contains incorrect configuration.
**Solution:**
1. Ensure parameter names match the expected format exactly.
1. Verify the database name is **not** included in the URL.
1. Use the correct format for the URL: `hostname:port`.
1. Check that string values are properly quoted in the YAML file.
1. Refer to the [provisioning example](../configure/#provision-the-data-source) for the correct format.
Example correct configuration:
```yaml
datasources:
- name: Postgres
type: postgres
url: localhost:5432
user: grafana
secureJsonData:
password: 'Password!'
jsonData:
database: grafana
sslmode: 'disable'
```
## Other common issues
The following issues don't produce specific error messages but are commonly encountered.
### Empty query results
**Cause:** The query returns no data.
**Solution:**
1. Verify the time range includes data in your database.
1. Check that table and column names are correct.
1. Test the query directly in PostgreSQL.
1. Ensure filters are not excluding all data.
1. Verify the `$__timeFilter` macro is using the correct time column.
### TimescaleDB functions not available
**Cause:** TimescaleDB-specific functions like `time_bucket` are not available in the query builder.
**Solution:**
1. Enable the **TimescaleDB** toggle in the data source configuration under **PostgreSQL Options**.
1. Verify TimescaleDB is installed and enabled in your PostgreSQL database.
1. Check that the `timescaledb` extension is created: `CREATE EXTENSION IF NOT EXISTS timescaledb;`
### Data appears delayed or missing recent points
**Cause:** The visualization doesn't show the most recent data.
**Solution:**
1. Check the dashboard time range and refresh settings.
1. Verify the **Min time interval** is not set too high.
1. Ensure data has been committed to the database (not in an uncommitted transaction).
1. Check for clock synchronization issues between Grafana and PostgreSQL.
## Get additional help
If you continue to experience issues after following this troubleshooting guide:
1. Check the [PostgreSQL documentation](https://www.postgresql.org/docs/) for database-specific guidance.
1. Review the [Grafana community forums](https://community.grafana.com/) for similar issues.
1. Contact Grafana Support if you are a Cloud Pro, Cloud Contracted, or Enterprise user.
1. When reporting issues, include:
- Grafana version
- PostgreSQL version
- Error messages (redact sensitive information)
- Steps to reproduce
- Relevant configuration such as data source settings, TLS mode, and connection limits (redact passwords and other credentials)
@@ -223,17 +223,25 @@ To export a dashboard in its current state as a PDF, follow these steps:
1. Click the **X** at the top-right corner to close the share drawer.
### Export a dashboard as JSON
### Export a dashboard as code
Export a Grafana JSON file that contains everything you need, including layout, variables, styles, data sources, queries, and so on, so that you can later import the dashboard. To export a JSON file, follow these steps:
1. Click **Dashboards** in the main menu.
1. Open the dashboard you want to export.
1. Click the **Export** drop-down list in the top-right corner and select **Export as JSON**.
1. Click the **Export** drop-down list in the top-right corner and select **Export as code**.
The **Export dashboard JSON** drawer opens.
The **Export dashboard** drawer opens.
1. Select the dashboard JSON model that you to export:
- **Classic** - Export dashboards created using the [current dashboard schema](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/visualizations/dashboards/build-dashboards/view-dashboard-json-model/).
- **V1 Resource** - Export dashboards created using the [current dashboard schema](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/visualizations/dashboards/build-dashboards/view-dashboard-json-model/) wrapped in the `spec` property of the [V1 Kubernetes-style resource](https://play.grafana.org/swagger?api=dashboard.grafana.app-v2alpha1). Choose between **JSON** and **YAML** format.
- **V2 Resource** - Export dashboards created using the [V2 Resource schema](https://play.grafana.org/swagger?api=dashboard.grafana.app-v2beta1). Choose between **JSON** and **YAML** format.
1. Do one of the following:
- Toggle the **Export for sharing externally** switch to generate the JSON with a different data source UID.
- Toggle the **Remove deployment details** switch to make the dashboard externally shareable.
1. Toggle the **Export the dashboard to use in another instance** switch to generate the JSON with a different data source UID.
1. Click **Download file** or **Copy to clipboard**.
1. Click the **X** at the top-right corner to close the share drawer.
@@ -343,6 +343,33 @@ test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table']
// TODO -- saving for another day.
});
test('Tests nested table expansion', async ({ gotoDashboardPage, selectors, page }) => {
const dashboardPage = await gotoDashboardPage({
uid: DASHBOARD_UID,
queryParams: new URLSearchParams({ editPanel: '4' }),
});
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Nested tables'))
).toBeVisible();
await waitForTableLoad(page);
await expect(page.locator('[role="row"]')).toHaveCount(3); // header + 2 rows
const firstRowExpander = dashboardPage
.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.RowExpander)
.first();
await firstRowExpander.click();
await expect(page.locator('[role="row"]')).not.toHaveCount(3); // more rows are present now, it is dynamic tho.
// TODO: test sorting
await firstRowExpander.click();
await expect(page.locator('[role="row"]')).toHaveCount(3); // back to original state
});
test('Tests tooltip interactions', async ({ gotoDashboardPage, selectors }) => {
const dashboardPage = await gotoDashboardPage({
uid: DASHBOARD_UID,
-5
View File
@@ -804,11 +804,6 @@
"count": 2
}
},
"packages/grafana-ui/src/components/Table/TableNG/utils.ts": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
}
},
"packages/grafana-ui/src/components/Table/TableRT/Filter.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
+36
View File
@@ -575,6 +575,42 @@ module.exports = [
"Property[key.name='a11y'][value.type='ObjectExpression'] Property[key.name='test'][value.value='off']",
message: 'Skipping a11y tests is not allowed. Please fix the component or story instead.',
},
{
selector: 'MemberExpression[object.name="config"][property.name="apps"]',
message:
'Usage of config.apps is not allowed. Use the function getAppPluginMetas() from @grafana/runtime instead',
},
],
},
},
{
files: [...commonTestIgnores],
ignores: [
// FIXME: Remove once all enterprise issues are fixed -
// we don't have a suppressions file/approach for enterprise code yet
...enterpriseIgnores,
],
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'MemberExpression[object.name="config"][property.name="apps"]',
message:
'Usage of config.apps is not allowed. Use the function getAppPluginMetas() from @grafana/runtime instead',
},
],
},
},
{
files: [...enterpriseIgnores],
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'MemberExpression[object.name="config"][property.name="apps"]',
message:
'Usage of config.apps is not allowed. Use the function getAppPluginMetas() from @grafana/runtime instead',
},
],
},
},
@@ -499,6 +499,9 @@ export const versionedComponents = {
},
},
TableNG: {
RowExpander: {
'12.4.0': 'data-testid tableng row expander',
},
Filters: {
HeaderButton: {
'12.1.0': 'data-testid tableng header filter',
+1
View File
@@ -86,6 +86,7 @@ export class GrafanaBootConfig {
snapshotEnabled = true;
datasources: { [str: string]: DataSourceInstanceSettings } = {};
panels: { [key: string]: PanelPluginMeta } = {};
/** @deprecated it will be removed in a future release, use getAppPluginMetas function or useAppPluginMetas hook instead */
apps: Record<string, AppPluginConfigGrafanaData> = {};
auth: AuthSettings = {};
minRefreshInterval = '';
@@ -29,3 +29,4 @@ export {
export { UserStorage } from '../utils/userStorage';
export { initOpenFeature, evaluateBooleanFlag } from './openFeature';
export { setAppPluginMetas } from '../services/plugins';
@@ -0,0 +1,90 @@
import { cloneDeep } from 'lodash';
import { useAsync } from 'react-use';
import { AppPluginConfig } from '@grafana/data';
import { config } from '../config';
export type AppPluginMetas = Record<string, AppPluginConfig>;
let apps: AppPluginMetas = {};
let appsPromise: Promise<void> | undefined = undefined;
function areAppsInitialized(): boolean {
return Boolean(Object.keys(apps).length);
}
async function initPluginMetas(): Promise<void> {
if (appsPromise) {
return appsPromise;
}
appsPromise = new Promise((resolve) => {
if (config.featureToggles.useMTPlugins) {
// add loading app configs from MT API here
apps = {};
resolve();
return;
}
// eslint-disable-next-line no-restricted-syntax
apps = config.apps;
resolve();
return;
});
return appsPromise;
}
export async function getAppPluginMetas(): Promise<AppPluginConfig[]> {
if (!areAppsInitialized()) {
await initPluginMetas();
}
return Object.values(cloneDeep(apps));
}
export async function getAppPluginMeta(id: string): Promise<AppPluginConfig | undefined> {
if (!areAppsInitialized()) {
await initPluginMetas();
}
if (!apps[id]) {
return undefined;
}
return cloneDeep(apps[id]);
}
export function setAppPluginMetas(override: AppPluginMetas) {
// We allow overriding apps in tests
if (override && process.env.NODE_ENV !== 'test') {
throw new Error('setAppPluginMetas() function can only be called from tests.');
}
apps = { ...override };
}
export interface UseAppPluginMetasResult {
isAppPluginMetasLoading: boolean;
error: Error | undefined;
apps: AppPluginConfig[];
}
export function useAppPluginMetas(filterByIds: string[] = []): UseAppPluginMetasResult {
const { loading, error, value: apps = [] } = useAsync(getAppPluginMetas);
const filtered = apps.filter((app) => filterByIds.includes(app.id));
return { isAppPluginMetasLoading: loading, error, apps: filtered };
}
export interface UseAppPluginMetaResult {
isAppPluginMetaLoading: boolean;
error: Error | undefined;
app: AppPluginConfig | undefined;
}
export function useAppPluginMeta(filterById: string): UseAppPluginMetaResult {
const { loading, error, value: app } = useAsync(() => getAppPluginMeta(filterById));
return { isAppPluginMetaLoading: loading, error, app };
}
+10
View File
@@ -11,3 +11,13 @@
// This is a dummy export so typescript doesn't error importing an "empty module"
export const unstable = {};
export {
type AppPluginMetas,
type UseAppPluginMetaResult,
type UseAppPluginMetasResult,
getAppPluginMeta,
getAppPluginMetas,
useAppPluginMeta,
useAppPluginMetas,
} from './services/plugins';
@@ -154,8 +154,18 @@ export function TableNG(props: TableNGProps) {
const resizeHandler = useColumnResize(onColumnResize);
const rows = useMemo(() => frameToRecords(data), [data]);
const hasNestedFrames = useMemo(() => getIsNestedTable(data.fields), [data]);
const nestedFramesFieldName = useMemo(() => {
if (!hasNestedFrames) {
return;
}
const firstNestedField = data.fields.find((f) => f.type === FieldType.nestedFrames);
if (!firstNestedField) {
return;
}
return getDisplayName(firstNestedField);
}, [data, hasNestedFrames]);
const rows = useMemo(() => frameToRecords(data, nestedFramesFieldName), [data, nestedFramesFieldName]);
const getTextColorForBackground = useMemo(() => memoize(_getTextColorForBackground, { maxSize: 1000 }), []);
const {
@@ -374,7 +384,11 @@ export function TableNG(props: TableNGProps) {
return null;
}
const expandedRecords = applySort(frameToRecords(nestedData), nestedData.fields, sortColumns);
const expandedRecords = applySort(
frameToRecords(nestedData, nestedFramesFieldName),
nestedData.fields,
sortColumns
);
if (!expandedRecords.length) {
return (
<div className={styles.noDataNested}>
@@ -398,7 +412,7 @@ export function TableNG(props: TableNGProps) {
width: COLUMN.EXPANDER_WIDTH,
minWidth: COLUMN.EXPANDER_WIDTH,
}),
[commonDataGridProps, data.fields.length, expandedRows, sortColumns, styles]
[commonDataGridProps, data.fields.length, expandedRows, sortColumns, styles, nestedFramesFieldName]
);
const fromFields = useCallback(
@@ -1,6 +1,7 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { useStyles2 } from '../../../../themes/ThemeContext';
@@ -16,13 +17,21 @@ export function RowExpander({ onCellExpand, isExpanded }: RowExpanderNGProps) {
}
}
return (
<div role="button" tabIndex={0} className={styles.expanderCell} onClick={onCellExpand} onKeyDown={handleKeyDown}>
<div
role="button"
tabIndex={0}
className={styles.expanderCell}
onClick={onCellExpand}
onKeyDown={handleKeyDown}
data-testid={selectors.components.Panels.Visualization.TableNG.RowExpander}
>
<Icon
aria-label={
isExpanded
? t('grafana-ui.row-expander-ng.aria-label-collapse', 'Collapse row')
: t('grafana-ui.row-expander.aria-label-expand', 'Expand row')
}
aria-expanded={isExpanded}
name={isExpanded ? 'angle-down' : 'angle-right'}
size="lg"
/>
@@ -79,7 +79,6 @@ export interface TableRow {
// Nested table properties
data?: DataFrame;
__nestedFrames?: DataFrame[];
__expanded?: boolean; // For row expansion state
// Generic typing for column values
@@ -262,7 +261,7 @@ export type TableCellStyles = (theme: GrafanaTheme2, options: TableCellStyleOpti
export type Comparator = (a: TableCellValue, b: TableCellValue) => number;
// Type for converting a DataFrame into an array of TableRows
export type FrameToRowsConverter = (frame: DataFrame) => TableRow[];
export type FrameToRowsConverter = (frame: DataFrame, nestedFramesFieldName?: string) => TableRow[];
// Type for mapping column names to their field types
export type ColumnTypes = Record<string, FieldType>;
@@ -675,10 +675,12 @@ export function applySort(
/**
* @internal
*/
export const frameToRecords = (frame: DataFrame): TableRow[] => {
export const frameToRecords = (frame: DataFrame, nestedFramesFieldName?: string): TableRow[] => {
const fnBody = `
const rows = Array(frame.length);
const values = frame.fields.map(f => f.values);
const hasNestedFrames = '${nestedFramesFieldName ?? ''}'.length > 0;
let rowCount = 0;
for (let i = 0; i < frame.length; i++) {
rows[rowCount] = {
@@ -686,11 +688,14 @@ export const frameToRecords = (frame: DataFrame): TableRow[] => {
__index: i,
${frame.fields.map((field, fieldIdx) => `${JSON.stringify(getDisplayName(field))}: values[${fieldIdx}][i]`).join(',')}
};
rowCount += 1;
if (rows[rowCount-1]['__nestedFrames']){
const childFrame = rows[rowCount-1]['__nestedFrames'];
rows[rowCount] = {__depth: 1, __index: i, data: childFrame[0]}
rowCount += 1;
rowCount++;
if (hasNestedFrames) {
const childFrame = rows[rowCount-1][${JSON.stringify(nestedFramesFieldName)}];
if (childFrame){
rows[rowCount] = {__depth: 1, __index: i, data: childFrame[0]}
rowCount++;
}
}
}
return rows;
@@ -698,8 +703,9 @@ export const frameToRecords = (frame: DataFrame): TableRow[] => {
// Creates a function that converts a DataFrame into an array of TableRows
// Uses new Function() for performance as it's faster than creating rows using loops
const convert = new Function('frame', fnBody) as FrameToRowsConverter;
return convert(frame);
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const convert = new Function('frame', 'nestedFramesFieldName', fnBody) as FrameToRowsConverter;
return convert(frame, nestedFramesFieldName);
};
/* ----------------------------- Data grid comparator ---------------------------- */
+3 -7
View File
@@ -99,10 +99,9 @@ import { usePluginComponent } from './features/plugins/extensions/usePluginCompo
import { usePluginComponents } from './features/plugins/extensions/usePluginComponents';
import { usePluginFunctions } from './features/plugins/extensions/usePluginFunctions';
import { usePluginLinks } from './features/plugins/extensions/usePluginLinks';
import { getAppPluginsToAwait, getAppPluginsToPreload } from './features/plugins/extensions/utils';
import { importPanelPlugin, syncGetPanelPlugin } from './features/plugins/importPanelPlugin';
import { initSystemJSHooks } from './features/plugins/loader/systemjsHooks';
import { preloadPlugins } from './features/plugins/pluginPreloader';
import { preloadPluginsToBeAwaited, preloadPluginsToBePreloaded } from './features/plugins/pluginPreloader';
import { QueryRunner } from './features/query/state/QueryRunner';
import { runRequest } from './features/query/state/runRequest';
import { initWindowRuntime } from './features/runtime/init';
@@ -257,11 +256,8 @@ export class GrafanaApp {
const skipAppPluginsPreload =
config.featureToggles.rendererDisableAppPluginsPreload && contextSrv.user.authenticatedBy === 'render';
if (contextSrv.user.orgRole !== '' && !skipAppPluginsPreload) {
const appPluginsToAwait = getAppPluginsToAwait();
const appPluginsToPreload = getAppPluginsToPreload();
preloadPlugins(appPluginsToPreload);
await preloadPlugins(appPluginsToAwait);
preloadPluginsToBePreloaded();
await preloadPluginsToBeAwaited();
}
setHelpNavItemHook(useHelpNode);
@@ -3,6 +3,7 @@ import { useLocalStorage } from 'react-use';
import { PluginExtensionPoints, store } from '@grafana/data';
import { getAppEvents, reportInteraction, usePluginLinks, locationService } from '@grafana/runtime';
import { useAppPluginMetas } from '@grafana/runtime/unstable';
import { ExtensionPointPluginMeta, getExtensionPointPluginMeta } from 'app/features/plugins/extensions/utils';
import { CloseExtensionSidebarEvent, OpenExtensionSidebarEvent, ToggleExtensionSidebarEvent } from 'app/types/events';
@@ -90,19 +91,21 @@ export const ExtensionSidebarContextProvider = ({ children }: ExtensionSidebarCo
// that means, a plugin would need to register both, a link and a component to
// `grafana/extension-sidebar/v0-alpha` and the link's `configure` method would control
// whether the component is rendered or not
const { links, isLoading } = usePluginLinks({
const { links, isLoading: isPluginLinksLoading } = usePluginLinks({
extensionPointId: PluginExtensionPoints.ExtensionSidebar,
context: {
path: currentPath,
},
});
const { apps, isAppPluginMetasLoading: isAppPluginConfigsLoading } = useAppPluginMetas();
const isLoading = isPluginLinksLoading || isAppPluginConfigsLoading;
// get all components for this extension point, but only for the permitted plugins
// if the extension sidebar is not enabled, we will return an empty map
const availableComponents = useMemo(
() =>
new Map(
Array.from(getExtensionPointPluginMeta(PluginExtensionPoints.ExtensionSidebar).entries()).filter(
Array.from(getExtensionPointPluginMeta(apps, PluginExtensionPoints.ExtensionSidebar).entries()).filter(
([pluginId, pluginMeta]) =>
PERMITTED_EXTENSION_SIDEBAR_PLUGINS.includes(pluginId) &&
links.some(
@@ -112,7 +115,7 @@ export const ExtensionSidebarContextProvider = ({ children }: ExtensionSidebarCo
)
)
),
[links]
[links, apps]
);
// check if the stored docked component is still available
@@ -7,6 +7,7 @@ import { getProxyApiUrl } from './onCallApi';
describe('getProxyApiUrl', () => {
it('should return URL with IRM plugin ID when IRM plugin is present', () => {
// eslint-disable-next-line no-restricted-syntax
config.apps = { [SupportedPlugin.Irm]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Irm]) };
expect(getProxyApiUrl('/alert_receive_channels/')).toBe(
@@ -15,6 +16,7 @@ describe('getProxyApiUrl', () => {
});
it('should return URL with OnCall plugin ID when IRM plugin is not present', () => {
// eslint-disable-next-line no-restricted-syntax
config.apps = {
[SupportedPlugin.OnCall]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.OnCall]),
[SupportedPlugin.Incident]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Incident]),
@@ -67,6 +67,7 @@ describe('filterRulerRulesConfig', () => {
};
it('should filter by namespace', () => {
// eslint-disable-next-line no-restricted-syntax
config.apps = { [SupportedPlugin.Slo]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Slo]) };
const { filteredConfig, someRulesAreSkipped } = filterRulerRulesConfig(mockRulesConfig, 'namespace1');
@@ -214,6 +214,7 @@ export function setGrafanaPromRules(groups: GrafanaPromRuleGroupDTO[]) {
/** Make a given plugin ID respond with a 404, as if it isn't installed at all */
export const removePlugin = (pluginId: string) => {
// eslint-disable-next-line no-restricted-syntax
delete config.apps[pluginId];
server.use(getPluginMissingHandler(pluginId));
};
@@ -12,6 +12,7 @@ const PLUGIN_NOT_FOUND_RESPONSE = { message: 'Plugin not found, no installed plu
*/
export const getPluginsHandler = (pluginsArray: PluginMeta[] = plugins) => {
plugins.forEach(({ id, baseUrl, info, angular }) => {
// eslint-disable-next-line no-restricted-syntax
config.apps[id] = {
id,
path: baseUrl,
@@ -137,6 +137,7 @@ describe('cloneRuleDefinition', () => {
it('Should remove the origin label when cloning data source plugin-provided rules', () => {
// Mock the plugin as installed
// eslint-disable-next-line no-restricted-syntax
config.apps = {
[SupportedPlugin.Slo]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Slo]),
};
@@ -174,6 +175,7 @@ describe('cloneRuleDefinition', () => {
});
it('Should remove the origin label when cloning Grafana-managed plugin-provided rules', () => {
// eslint-disable-next-line no-restricted-syntax
config.apps = {
[SupportedPlugin.Slo]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Slo]),
};
@@ -62,11 +62,13 @@ describe('checkEvaluationIntervalGlobalLimit', () => {
describe('getIsIrmPluginPresent', () => {
it('should return true when IRM plugin is present in config.apps', () => {
// eslint-disable-next-line no-restricted-syntax
config.apps = { [SupportedPlugin.Irm]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Irm]) };
expect(getIsIrmPluginPresent()).toBe(true);
});
it('should return false when IRM plugin is not present in config.apps', () => {
// eslint-disable-next-line no-restricted-syntax
config.apps = {
[SupportedPlugin.OnCall]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.OnCall]),
[SupportedPlugin.Incident]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Incident]),
@@ -77,11 +79,13 @@ describe('getIsIrmPluginPresent', () => {
describe('getIrmIfPresentOrIncidentPluginId', () => {
it('should return IRM plugin ID when IRM plugin is present', () => {
// eslint-disable-next-line no-restricted-syntax
config.apps = { [SupportedPlugin.Irm]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Irm]) };
expect(getIrmIfPresentOrIncidentPluginId()).toBe(SupportedPlugin.Irm);
});
it('should return Incident plugin ID when IRM plugin is not present', () => {
// eslint-disable-next-line no-restricted-syntax
config.apps = {
[SupportedPlugin.OnCall]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.OnCall]),
[SupportedPlugin.Incident]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Incident]),
@@ -92,11 +96,13 @@ describe('getIrmIfPresentOrIncidentPluginId', () => {
describe('getIrmIfPresentOrOnCallPluginId', () => {
it('should return IRM plugin ID when IRM plugin is present', () => {
// eslint-disable-next-line no-restricted-syntax
config.apps = { [SupportedPlugin.Irm]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Irm]) };
expect(getIrmIfPresentOrOnCallPluginId()).toBe(SupportedPlugin.Irm);
});
it('should return OnCall plugin ID when IRM plugin is not present', () => {
// eslint-disable-next-line no-restricted-syntax
config.apps = {
[SupportedPlugin.OnCall]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.OnCall]),
[SupportedPlugin.Incident]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Incident]),
@@ -30,6 +30,7 @@ export function checkEvaluationIntervalGlobalLimit(alertGroupEvaluateEvery?: str
}
export function getIsIrmPluginPresent() {
// eslint-disable-next-line no-restricted-syntax
return SupportedPlugin.Irm in config.apps;
}
@@ -42,6 +42,7 @@ describe('getRuleOrigin', () => {
});
it('returns pluginId when origin label matches expected format and plugin is installed', () => {
// eslint-disable-next-line no-restricted-syntax
config.apps = {
installed_plugin: {
id: 'installed_plugin',
@@ -273,6 +273,7 @@ export function getRulePluginOrigin(rule?: Rule | PromRuleDTO | RulerRuleDTO): R
}
function isPluginInstalled(pluginId: string) {
// eslint-disable-next-line no-restricted-syntax
return Boolean(config.apps[pluginId]);
}
@@ -3,12 +3,14 @@ import userEvent from '@testing-library/user-event';
import { PluginLoadingStrategy } from '@grafana/data';
import { config } from '@grafana/runtime';
import { setAppPluginMetas } from '@grafana/runtime/internal';
import { contextSrv } from 'app/core/services/context_srv';
import { AdvisorRedirectNotice } from './AdvisorRedirectNotice';
const originalFeatureToggleValue = config.featureToggles.grafanaAdvisor;
jest.mock('@grafana/runtime/internal', () => ({
...jest.requireActual('@grafana/runtime/internal'),
UserStorage: jest.fn().mockImplementation(() => ({
getItem: jest.fn().mockResolvedValue('true'),
setItem: jest.fn().mockResolvedValue(undefined),
@@ -24,27 +26,29 @@ describe('AdvisorRedirectNotice', () => {
afterEach(() => {
jest.clearAllMocks();
config.featureToggles.grafanaAdvisor = originalFeatureToggleValue;
config.apps['grafana-advisor-app'] = {
id: 'grafana-advisor-app',
path: '/a/grafana-advisor-app',
version: '1.0.0',
preload: false,
angular: { detected: false, hideDeprecation: false },
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaDependency: '*',
grafanaVersion: '*',
plugins: [],
extensions: { exposedComponents: [] },
setAppPluginMetas({
'grafana-advisor-app': {
id: 'grafana-advisor-app',
path: '/a/grafana-advisor-app',
version: '1.0.0',
preload: false,
angular: { detected: false, hideDeprecation: false },
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaDependency: '*',
grafanaVersion: '*',
plugins: [],
extensions: { exposedComponents: [] },
},
extensions: {
addedLinks: [],
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
addedFunctions: [],
},
},
extensions: {
addedLinks: [],
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
addedFunctions: [],
},
};
});
});
it('should not render when user is not admin', async () => {
@@ -60,7 +64,7 @@ describe('AdvisorRedirectNotice', () => {
});
it('should not render when app is not installed', async () => {
delete config.apps['grafana-advisor-app'];
setAppPluginMetas({});
render(<AdvisorRedirectNotice />);
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
@@ -5,6 +5,7 @@ import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { UserStorage } from '@grafana/runtime/internal';
import { useAppPluginMeta } from '@grafana/runtime/unstable';
import { Alert, LinkButton, useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
@@ -27,8 +28,9 @@ export function AdvisorRedirectNotice() {
const styles = useStyles2(getStyles);
const hasAdminRights = contextSrv.hasRole('Admin') || contextSrv.isGrafanaAdmin;
const [showNotice, setShowNotice] = useState(false);
const { app } = useAppPluginMeta('grafana-advisor-app');
const canUseAdvisor = hasAdminRights && config.featureToggles.grafanaAdvisor && !!config.apps['grafana-advisor-app'];
const canUseAdvisor = hasAdminRights && config.featureToggles.grafanaAdvisor && !!app;
useEffect(() => {
if (canUseAdvisor) {
@@ -25,10 +25,17 @@ import { DashboardDataDTO } from 'app/types/dashboard';
import { PanelInspectDrawer } from '../../inspect/PanelInspectDrawer';
import { PanelTimeRange, PanelTimeRangeState } from '../../scene/panel-timerange/PanelTimeRange';
import { DashboardLayoutManager } from '../../scene/types/DashboardLayoutManager';
import { transformSaveModelSchemaV2ToScene } from '../../serialization/transformSaveModelSchemaV2ToScene';
import { transformSaveModelToScene } from '../../serialization/transformSaveModelToScene';
import { findVizPanelByKey } from '../../utils/utils';
import { buildPanelEditScene } from '../PanelEditor';
import { testDashboard, panelWithTransformations, panelWithQueriesOnly } from '../testfiles/testDashboard';
import {
testDashboard,
panelWithTransformations,
panelWithQueriesOnly,
testDashboardV2,
} from '../testfiles/testDashboard';
import { PanelDataQueriesTab, PanelDataQueriesTabRendered } from './PanelDataQueriesTab';
@@ -824,6 +831,78 @@ describe('PanelDataQueriesTab', () => {
expect(queriesTab.state.dsSettings?.uid).toBe('gdev-testdata');
});
});
describe('V2 schema behavior - panel datasource undefined but queries have datasource', () => {
it('should load datasource from first query for V2 panel with prometheus datasource', async () => {
// panel-1 has a query with prometheus datasource
const { queriesTab } = await setupV2Scene('panel-1');
// V2 panels have undefined panel-level datasource for non-mixed panels
expect(queriesTab.queryRunner.state.datasource).toBeUndefined();
// But the query has its own datasource
expect(queriesTab.queryRunner.state.queries[0].datasource).toEqual({
type: 'grafana-prometheus-datasource',
uid: 'gdev-prometheus',
});
// Should load the datasource from the first query
expect(queriesTab.state.datasource?.uid).toBe('gdev-prometheus');
expect(queriesTab.state.dsSettings?.uid).toBe('gdev-prometheus');
});
it('should load datasource from first query for V2 panel with testdata datasource', async () => {
// panel-2 has a query with testdata datasource
const { queriesTab } = await setupV2Scene('panel-2');
// V2 panels have undefined panel-level datasource for non-mixed panels
expect(queriesTab.queryRunner.state.datasource).toBeUndefined();
// But the query has its own datasource
expect(queriesTab.queryRunner.state.queries[0].datasource).toEqual({
type: 'grafana-testdata-datasource',
uid: 'gdev-testdata',
});
// Should load the datasource from the first query
expect(queriesTab.state.datasource?.uid).toBe('gdev-testdata');
expect(queriesTab.state.dsSettings?.uid).toBe('gdev-testdata');
});
it('should fall back to last used datasource when V2 query has no explicit datasource', async () => {
store.exists.mockReturnValue(true);
store.getObject.mockImplementation((key: string, def: unknown) => {
if (key === PANEL_EDIT_LAST_USED_DATASOURCE) {
return {
dashboardUid: 'v2-dashboard-uid',
datasourceUid: 'gdev-testdata',
};
}
return def;
});
// panel-3 has a query with NO explicit datasource (datasource.name is undefined)
const { queriesTab } = await setupV2Scene('panel-3');
// V2 panel with no explicit datasource on query should fall back to last used
expect(queriesTab.state.datasource?.uid).toBe('gdev-testdata');
expect(queriesTab.state.dsSettings?.uid).toBe('gdev-testdata');
});
it('should use panel-level datasource when available (V1 behavior preserved)', async () => {
const { queriesTab } = await setupScene('panel-1');
// V1 panels have panel-level datasource set
expect(queriesTab.queryRunner.state.datasource).toEqual({
uid: 'gdev-testdata',
type: 'grafana-testdata-datasource',
});
// Should use the panel-level datasource
expect(queriesTab.state.datasource?.uid).toBe('gdev-testdata');
expect(queriesTab.state.dsSettings?.uid).toBe('gdev-testdata');
});
});
});
});
@@ -844,3 +923,24 @@ async function setupScene(panelId: string) {
return { panel, scene: dashboard, queriesTab };
}
// Setup V2 scene - uses transformSaveModelSchemaV2ToScene
async function setupV2Scene(panelKey: string) {
const dashboard = transformSaveModelSchemaV2ToScene(testDashboardV2);
const vizPanels = (dashboard.state.body as DashboardLayoutManager).getVizPanels();
const panel = vizPanels.find((p) => p.state.key === panelKey)!;
const panelEditor = buildPanelEditScene(panel);
dashboard.setState({ editPanel: panelEditor });
deactivators.push(dashboard.activate());
deactivators.push(panelEditor.activate());
const queriesTab = panelEditor.state.dataPane!.state.tabs[0] as PanelDataQueriesTab;
deactivators.push(queriesTab.activate());
await Promise.resolve();
return { panel, scene: dashboard, queriesTab };
}
@@ -86,6 +86,17 @@ export class PanelDataQueriesTab extends SceneObjectBase<PanelDataQueriesTabStat
let datasource: DataSourceApi | undefined;
let dsSettings: DataSourceInstanceSettings | undefined;
// If no panel-level datasource (V2 schema non-mixed case), infer from first query
// This also improves the V1 behavior because it doesn't make sense to rely on last used
// if underlying queries have different datasources
if (!datasourceToLoad) {
const queries = this.queryRunner.state.queries;
const firstQueryDs = queries[0]?.datasource;
if (firstQueryDs) {
datasourceToLoad = firstQueryDs;
}
}
if (!datasourceToLoad) {
const dashboardScene = getDashboardSceneFor(this);
const dashboardUid = dashboardScene.state.uid ?? '';
@@ -1,3 +1,6 @@
import { Spec as DashboardV2Spec, defaultDataQueryKind } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
export const panelWithQueriesOnly = {
datasource: {
type: 'grafana-testdata-datasource',
@@ -751,3 +754,223 @@ export const testDashboard = {
version: 6,
weekStart: '',
};
// V2 Dashboard fixture - panels have queries with datasources but NO panel-level datasource
export const testDashboardV2: DashboardWithAccessInfo<DashboardV2Spec> = {
kind: 'DashboardWithAccessInfo',
metadata: {
name: 'v2-dashboard-uid',
namespace: 'default',
labels: {},
generation: 1,
resourceVersion: '1',
creationTimestamp: new Date().toISOString(),
},
spec: {
title: 'V2 Test Dashboard',
description: 'Test dashboard for V2 schema',
tags: [],
cursorSync: 'Off',
liveNow: false,
editable: true,
preload: false,
links: [],
variables: [],
annotations: [],
timeSettings: {
from: 'now-6h',
to: 'now',
autoRefresh: '',
autoRefreshIntervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d'],
fiscalYearStartMonth: 0,
hideTimepicker: false,
timezone: '',
weekStart: undefined,
quickRanges: [],
},
elements: {
'panel-1': {
kind: 'Panel',
spec: {
id: 1,
title: 'Panel with Prometheus datasource',
description: '',
links: [],
data: {
kind: 'QueryGroup',
spec: {
queries: [
{
kind: 'PanelQuery',
spec: {
refId: 'A',
hidden: false,
query: {
kind: 'DataQuery',
version: defaultDataQueryKind().version,
group: 'grafana-prometheus-datasource',
datasource: {
name: 'gdev-prometheus',
},
spec: {
expr: 'up',
},
},
},
},
],
transformations: [],
queryOptions: {},
},
},
vizConfig: {
kind: 'VizConfig',
group: 'timeseries',
version: '1.0.0',
spec: {
options: {},
fieldConfig: {
defaults: {},
overrides: [],
},
},
},
},
},
'panel-2': {
kind: 'Panel',
spec: {
id: 2,
title: 'Panel with TestData datasource',
description: '',
links: [],
data: {
kind: 'QueryGroup',
spec: {
queries: [
{
kind: 'PanelQuery',
spec: {
refId: 'A',
hidden: false,
query: {
kind: 'DataQuery',
version: defaultDataQueryKind().version,
group: 'grafana-testdata-datasource',
datasource: {
name: 'gdev-testdata',
},
spec: {
scenarioId: 'random_walk',
},
},
},
},
],
transformations: [],
queryOptions: {},
},
},
vizConfig: {
kind: 'VizConfig',
group: 'timeseries',
version: '1.0.0',
spec: {
options: {},
fieldConfig: {
defaults: {},
overrides: [],
},
},
},
},
},
'panel-3': {
kind: 'Panel',
spec: {
id: 3,
title: 'Panel with no datasource on query',
description: '',
links: [],
data: {
kind: 'QueryGroup',
spec: {
queries: [
{
kind: 'PanelQuery',
spec: {
refId: 'A',
hidden: false,
query: {
kind: 'DataQuery',
version: defaultDataQueryKind().version,
group: 'grafana-testdata-datasource',
// No datasource.name - simulates panel with no explicit datasource
spec: {},
},
},
},
],
transformations: [],
queryOptions: {},
},
},
vizConfig: {
kind: 'VizConfig',
group: 'timeseries',
version: '1.0.0',
spec: {
options: {},
fieldConfig: {
defaults: {},
overrides: [],
},
},
},
},
},
},
layout: {
kind: 'GridLayout',
spec: {
items: [
{
kind: 'GridLayoutItem',
spec: {
x: 0,
y: 0,
width: 12,
height: 8,
element: { kind: 'ElementReference', name: 'panel-1' },
},
},
{
kind: 'GridLayoutItem',
spec: {
x: 12,
y: 0,
width: 12,
height: 8,
element: { kind: 'ElementReference', name: 'panel-2' },
},
},
{
kind: 'GridLayoutItem',
spec: {
x: 0,
y: 8,
width: 12,
height: 8,
element: { kind: 'ElementReference', name: 'panel-3' },
},
},
],
},
},
},
access: {
url: '/d/v2-dashboard-uid',
slug: 'v2-test-dashboard',
},
apiVersion: 'v2',
};
@@ -18,7 +18,8 @@ import { isDashboardLayoutGrid } from '../types/DashboardLayoutGrid';
import { RowItem } from './RowItem';
export function RowItemRenderer({ model }: SceneComponentProps<RowItem>) {
const { layout, collapse: isCollapsed, fillScreen, hideHeader: isHeaderHidden, isDropTarget, key } = model.useState();
const { layout, collapse, fillScreen, hideHeader: isHeaderHidden, isDropTarget, key } = model.useState();
const isCollapsed = collapse && !isHeaderHidden; // never allow a row without a header to be collapsed
const isClone = isRepeatCloneOrChildOf(model);
const { isEditing } = useDashboardState(model);
const [isConditionallyHidden, conditionalRenderingClass, conditionalRenderingOverlay] = useIsConditionallyHidden(
@@ -17,14 +17,9 @@ jest.mock('@grafana/llm', () => ({
},
}));
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
config: {
...jest.requireActual('@grafana/runtime').config,
apps: {
'grafana-llm-app': true,
},
},
jest.mock('@grafana/runtime/unstable', () => ({
...jest.requireActual('@grafana/runtime/unstable'),
getAppPluginMeta: () => Promise.resolve({}),
}));
describe('getDashboardChanges', () => {
@@ -1,7 +1,7 @@
import { pick } from 'lodash';
import { llm } from '@grafana/llm';
import { config } from '@grafana/runtime';
import { getAppPluginMeta } from '@grafana/runtime/unstable';
import { Panel } from '@grafana/schema';
import { DashboardModel } from '../../state/DashboardModel';
@@ -70,7 +70,8 @@ let llmHealthCheck: Promise<boolean> | undefined;
* @returns true if the LLM plugin is enabled.
*/
export async function isLLMPluginEnabled(): Promise<boolean> {
if (!config.apps['grafana-llm-app']) {
const app = await getAppPluginMeta('grafana-llm-app');
if (!app) {
return false;
}
@@ -2,7 +2,8 @@ import React from 'react';
import { firstValueFrom, take } from 'rxjs';
import { PluginLoadingStrategy } from '@grafana/data';
import { config } from '@grafana/runtime';
import { setAppPluginMetas } from '@grafana/runtime/internal';
import { getAppPluginMeta } from '@grafana/runtime/unstable';
import { log } from '../logs/log';
import { resetLogMock } from '../logs/testUtils';
@@ -30,7 +31,6 @@ jest.mock('../logs/log', () => {
});
describe('AddedComponentsRegistry', () => {
const originalApps = config.apps;
const pluginId = 'grafana-basic-app';
const appPluginConfig = {
id: pluginId,
@@ -61,13 +61,11 @@ describe('AddedComponentsRegistry', () => {
beforeEach(() => {
resetLogMock(log);
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
config.apps = {
[pluginId]: appPluginConfig,
};
setAppPluginMetas({ [pluginId]: appPluginConfig });
});
afterEach(() => {
config.apps = originalApps;
setAppPluginMetas({});
});
it('should return empty registry when no extensions registered', async () => {
@@ -450,7 +448,11 @@ describe('AddedComponentsRegistry', () => {
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedComponents = [];
const meta = await getAppPluginMeta(pluginId);
expect(meta).toBeDefined();
const app = { ...meta!, extensions: { ...meta!.extensions, addedComponents: [] } };
setAppPluginMetas({ [pluginId]: app });
registry.register({
pluginId,
@@ -499,7 +501,11 @@ describe('AddedComponentsRegistry', () => {
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedComponents = [];
const meta = await getAppPluginMeta(pluginId);
expect(meta).toBeDefined();
const app = { ...meta!, extensions: { ...meta!.extensions, addedComponents: [] } };
setAppPluginMetas({ [pluginId]: app });
registry.register({
pluginId,
@@ -525,7 +531,11 @@ describe('AddedComponentsRegistry', () => {
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedComponents = [componentConfig];
const meta = await getAppPluginMeta(pluginId);
expect(meta).toBeDefined();
const app = { ...meta!, extensions: { ...meta!.extensions, addedComponents: [componentConfig] } };
setAppPluginMetas({ [pluginId]: app });
registry.register({
pluginId,
@@ -30,10 +30,10 @@ export class AddedComponentsRegistry extends Registry<
super(options);
}
mapToRegistry(
async mapToRegistry(
registry: RegistryType<AddedComponentRegistryItem[]>,
item: PluginExtensionConfigs<PluginExtensionAddedComponentConfig>
): RegistryType<AddedComponentRegistryItem[]> {
): Promise<RegistryType<AddedComponentRegistryItem[]>> {
const { pluginId, configs } = item;
for (const config of configs) {
@@ -51,7 +51,7 @@ export class AddedComponentsRegistry extends Registry<
if (
pluginId !== 'grafana' &&
isGrafanaDevMode() &&
isAddedComponentMetaInfoMissing(pluginId, config, configLog)
(await isAddedComponentMetaInfoMissing(pluginId, config, configLog))
) {
continue;
}
@@ -1,7 +1,8 @@
import { firstValueFrom, take } from 'rxjs';
import { PluginLoadingStrategy } from '@grafana/data';
import { config } from '@grafana/runtime';
import { setAppPluginMetas } from '@grafana/runtime/internal';
import { getAppPluginMeta } from '@grafana/runtime/unstable';
import { log } from '../logs/log';
import { resetLogMock } from '../logs/testUtils';
@@ -29,7 +30,6 @@ jest.mock('../logs/log', () => {
});
describe('addedFunctionsRegistry', () => {
const originalApps = config.apps;
const pluginId = 'grafana-basic-app';
const appPluginConfig = {
id: pluginId,
@@ -60,13 +60,11 @@ describe('addedFunctionsRegistry', () => {
beforeEach(() => {
resetLogMock(log);
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
config.apps = {
[pluginId]: appPluginConfig,
};
setAppPluginMetas({ [pluginId]: appPluginConfig });
});
afterEach(() => {
config.apps = originalApps;
setAppPluginMetas({});
});
it('should return empty registry when no extensions registered', async () => {
@@ -642,7 +640,11 @@ describe('addedFunctionsRegistry', () => {
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedFunctions = [];
const meta = await getAppPluginMeta(pluginId);
expect(meta).toBeDefined();
const app = { ...meta!, extensions: { ...meta!.extensions, addedFunctions: [] } };
setAppPluginMetas({ [pluginId]: app });
registry.register({
pluginId,
@@ -691,7 +693,11 @@ describe('addedFunctionsRegistry', () => {
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedFunctions = [];
const meta = await getAppPluginMeta(pluginId);
expect(meta).toBeDefined();
const app = { ...meta!, extensions: { ...meta!.extensions, addedFunctions: [] } };
setAppPluginMetas({ [pluginId]: app });
registry.register({
pluginId,
@@ -717,7 +723,11 @@ describe('addedFunctionsRegistry', () => {
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedFunctions = [fnConfig];
const meta = await getAppPluginMeta(pluginId);
expect(meta).toBeDefined();
const app = { ...meta!, extensions: { ...meta!.extensions, addedFunctions: [fnConfig] } };
setAppPluginMetas({ [pluginId]: app });
registry.register({
pluginId,
@@ -28,11 +28,12 @@ export class AddedFunctionsRegistry extends Registry<AddedFunctionsRegistryItem[
super(options);
}
mapToRegistry(
async mapToRegistry(
registry: RegistryType<AddedFunctionsRegistryItem[]>,
item: PluginExtensionConfigs<PluginExtensionAddedFunctionConfig>
): RegistryType<AddedFunctionsRegistryItem[]> {
): Promise<RegistryType<AddedFunctionsRegistryItem[]>> {
const { pluginId, configs } = item;
for (const config of configs) {
const configLog = this.logger.child({
title: config.title,
@@ -49,7 +50,11 @@ export class AddedFunctionsRegistry extends Registry<AddedFunctionsRegistryItem[
continue;
}
if (pluginId !== 'grafana' && isGrafanaDevMode() && isAddedFunctionMetaInfoMissing(pluginId, config, configLog)) {
if (
pluginId !== 'grafana' &&
isGrafanaDevMode() &&
(await isAddedFunctionMetaInfoMissing(pluginId, config, configLog))
) {
continue;
}
@@ -1,7 +1,8 @@
import { firstValueFrom, take } from 'rxjs';
import { PluginLoadingStrategy } from '@grafana/data';
import { config } from '@grafana/runtime';
import { setAppPluginMetas } from '@grafana/runtime/internal';
import { getAppPluginMeta } from '@grafana/runtime/unstable';
import { log } from '../logs/log';
import { resetLogMock } from '../logs/testUtils';
@@ -29,7 +30,6 @@ jest.mock('../logs/log', () => {
});
describe('AddedLinksRegistry', () => {
const originalApps = config.apps;
const pluginId = 'grafana-basic-app';
const appPluginConfig = {
id: pluginId,
@@ -60,13 +60,11 @@ describe('AddedLinksRegistry', () => {
beforeEach(() => {
resetLogMock(log);
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
config.apps = {
[pluginId]: appPluginConfig,
};
setAppPluginMetas({ [pluginId]: appPluginConfig });
});
afterEach(() => {
config.apps = originalApps;
setAppPluginMetas({});
});
it('should return empty registry when no extensions registered', async () => {
@@ -626,7 +624,11 @@ describe('AddedLinksRegistry', () => {
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedLinks = [];
const meta = await getAppPluginMeta(pluginId);
expect(meta).toBeDefined();
const app = { ...meta!, extensions: { ...meta!.extensions, addedLinks: [] } };
setAppPluginMetas({ [pluginId]: app });
registry.register({
pluginId,
@@ -677,7 +679,11 @@ describe('AddedLinksRegistry', () => {
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedLinks = [];
const meta = await getAppPluginMeta(pluginId);
expect(meta).toBeDefined();
const app = { ...meta!, extensions: { ...meta!.extensions, addedLinks: [] } };
setAppPluginMetas({ [pluginId]: app });
registry.register({
pluginId,
@@ -704,7 +710,11 @@ describe('AddedLinksRegistry', () => {
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedLinks = [linkConfig];
const meta = await getAppPluginMeta(pluginId);
expect(meta).toBeDefined();
const app = { ...meta!, extensions: { ...meta!.extensions, addedLinks: [linkConfig] } };
setAppPluginMetas({ [pluginId]: app });
registry.register({
pluginId,
@@ -34,10 +34,10 @@ export class AddedLinksRegistry extends Registry<AddedLinkRegistryItem[], Plugin
super(options);
}
mapToRegistry(
async mapToRegistry(
registry: RegistryType<AddedLinkRegistryItem[]>,
item: PluginExtensionConfigs<PluginExtensionAddedLinkConfig>
): RegistryType<AddedLinkRegistryItem[]> {
): Promise<RegistryType<AddedLinkRegistryItem[]>> {
const { pluginId, configs } = item;
for (const config of configs) {
@@ -66,7 +66,11 @@ export class AddedLinksRegistry extends Registry<AddedLinkRegistryItem[], Plugin
continue;
}
if (pluginId !== 'grafana' && isGrafanaDevMode() && isAddedLinkMetaInfoMissing(pluginId, config, configLog)) {
if (
pluginId !== 'grafana' &&
isGrafanaDevMode() &&
(await isAddedLinkMetaInfoMissing(pluginId, config, configLog))
) {
continue;
}
@@ -2,7 +2,8 @@ import React from 'react';
import { firstValueFrom, take } from 'rxjs';
import { PluginLoadingStrategy } from '@grafana/data';
import { config } from '@grafana/runtime';
import { setAppPluginMetas } from '@grafana/runtime/internal';
import { getAppPluginMeta } from '@grafana/runtime/unstable';
import { log } from '../logs/log';
import { resetLogMock } from '../logs/testUtils';
@@ -30,7 +31,6 @@ jest.mock('../logs/log', () => {
});
describe('ExposedComponentsRegistry', () => {
const originalApps = config.apps;
const pluginId = 'grafana-basic-app';
const appPluginConfig = {
id: pluginId,
@@ -61,13 +61,11 @@ describe('ExposedComponentsRegistry', () => {
beforeEach(() => {
resetLogMock(log);
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
config.apps = {
[pluginId]: appPluginConfig,
};
setAppPluginMetas({ [pluginId]: appPluginConfig });
});
afterEach(() => {
config.apps = originalApps;
setAppPluginMetas({});
});
it('should return empty registry when no exposed components have been registered', async () => {
@@ -423,7 +421,11 @@ describe('ExposedComponentsRegistry', () => {
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.exposedComponents = [];
const meta = await getAppPluginMeta(pluginId);
expect(meta).toBeDefined();
const app = { ...meta!, extensions: { ...meta!.extensions, exposedComponents: [] } };
setAppPluginMetas({ [pluginId]: app });
registry.register({
pluginId,
@@ -472,7 +474,11 @@ describe('ExposedComponentsRegistry', () => {
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.exposedComponents = [];
const meta = await getAppPluginMeta(pluginId);
expect(meta).toBeDefined();
const app = { ...meta!, extensions: { ...meta!.extensions, exposedComponents: [] } };
setAppPluginMetas({ [pluginId]: app });
registry.register({
pluginId,
@@ -497,8 +503,11 @@ describe('ExposedComponentsRegistry', () => {
component: () => React.createElement('div', null, 'Hello World1'),
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.exposedComponents = [componentConfig];
const meta = await getAppPluginMeta(pluginId);
expect(meta).toBeDefined();
const app = { ...meta!, extensions: { ...meta!.extensions, exposedComponents: [componentConfig] } };
setAppPluginMetas({ [pluginId]: app });
registry.register({
pluginId,
@@ -30,10 +30,10 @@ export class ExposedComponentsRegistry extends Registry<
super(options);
}
mapToRegistry(
async mapToRegistry(
registry: RegistryType<ExposedComponentRegistryItem>,
{ pluginId, configs }: PluginExtensionConfigs<PluginExtensionExposedComponentConfig>
): RegistryType<ExposedComponentRegistryItem> {
): Promise<RegistryType<ExposedComponentRegistryItem>> {
if (!configs) {
return registry;
}
@@ -65,7 +65,7 @@ export class ExposedComponentsRegistry extends Registry<
if (
pluginId !== 'grafana' &&
isGrafanaDevMode() &&
isExposedComponentMetaInfoMissing(pluginId, config, pointIdLog)
(await isExposedComponentMetaInfoMissing(pluginId, config, pointIdLog))
) {
continue;
}
@@ -1,4 +1,13 @@
import { Observable, ReplaySubject, Subject, distinctUntilChanged, firstValueFrom, map, scan, startWith } from 'rxjs';
import {
Observable,
ReplaySubject,
Subject,
distinctUntilChanged,
firstValueFrom,
map,
mergeScan,
startWith,
} from 'rxjs';
import { ExtensionsLog, log } from '../logs/log';
import { deepFreeze } from '../utils';
@@ -44,7 +53,7 @@ export abstract class Registry<TRegistryValue extends object | unknown[] | Recor
this.registrySubject = new ReplaySubject<RegistryType<TRegistryValue>>(1);
this.resultSubject
.pipe(
scan(this.mapToRegistry.bind(this), options.initialState ?? {}),
mergeScan(this.mapToRegistry.bind(this), options.initialState ?? {}),
// Emit an empty registry to start the stream (it is only going to do it once during construction, and then just passes down the values)
startWith(options.initialState ?? {})
)
@@ -55,7 +64,7 @@ export abstract class Registry<TRegistryValue extends object | unknown[] | Recor
abstract mapToRegistry(
registry: RegistryType<TRegistryValue>,
item: PluginExtensionConfigs<TMapType>
): RegistryType<TRegistryValue>;
): Promise<RegistryType<TRegistryValue>>;
register(result: PluginExtensionConfigs<TMapType>): void {
if (this.isReadOnly) {
@@ -1,19 +1,12 @@
import { useAsync } from 'react-use';
import { preloadPlugins } from '../pluginPreloader';
import { PreloadAppPluginsPredicate, preloadPluginsWithPredicate } from '../pluginPreloader';
import { getAppPluginConfigs } from './utils';
export function useLoadAppPlugins(extensionId: string, predicate: PreloadAppPluginsPredicate): { isLoading: boolean } {
const { loading: isLoading } = useAsync(
() => preloadPluginsWithPredicate(extensionId, predicate),
[extensionId, predicate]
);
export function useLoadAppPlugins(pluginIds: string[] = []): { isLoading: boolean } {
const { loading: isLoading } = useAsync(async () => {
const appConfigs = getAppPluginConfigs(pluginIds);
if (!appConfigs.length) {
return;
}
await preloadPlugins(appConfigs);
});
return { isLoading };
return { isLoading: isLoading };
}
@@ -3,6 +3,7 @@ import type { JSX } from 'react';
import { PluginContextProvider, PluginLoadingStrategy, PluginMeta, PluginType } from '@grafana/data';
import { config } from '@grafana/runtime';
import { setAppPluginMetas } from '@grafana/runtime/internal';
import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
import { log } from './logs/log';
@@ -51,7 +52,6 @@ describe('usePluginComponent()', () => {
let registries: PluginExtensionRegistries;
let wrapper: ({ children }: { children: React.ReactNode }) => JSX.Element;
let pluginMeta: PluginMeta;
const originalApps = config.apps;
const pluginId = 'myorg-extensions-app';
const exposedComponentId = `${pluginId}/exposed-component/v1`;
const exposedComponentConfig = {
@@ -135,9 +135,7 @@ describe('usePluginComponent()', () => {
},
};
config.apps = {
[pluginId]: appPluginConfig,
};
setAppPluginMetas({ [pluginId]: appPluginConfig });
wrapper = ({ children }: { children: React.ReactNode }) => (
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
@@ -145,7 +143,7 @@ describe('usePluginComponent()', () => {
});
afterEach(() => {
config.apps = originalApps;
setAppPluginMetas({});
});
it('should return null if there are no component exposed for the id', () => {
@@ -15,7 +15,7 @@ import { isExposedComponentDependencyMissing } from './validators';
export function usePluginComponent<Props extends object = {}>(id: string): UsePluginComponentResult<Props> {
const registryItem = useExposedComponentRegistrySlice<Props>(id);
const pluginContext = usePluginContext();
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExposedComponentPluginDependencies(id));
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(id, getExposedComponentPluginDependencies);
return useMemo(() => {
// For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana.
@@ -9,6 +9,8 @@ import {
PluginType,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { setAppPluginMetas } from '@grafana/runtime/internal';
import { getAppPluginMeta } from '@grafana/runtime/unstable';
import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
import * as errors from './errors';
@@ -115,31 +117,33 @@ describe('usePluginComponents()', () => {
},
};
config.apps[pluginId] = {
id: pluginId,
path: '',
version: '',
preload: false,
angular: {
detected: false,
hideDeprecation: false,
},
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
setAppPluginMetas({
[pluginId]: {
id: pluginId,
path: '',
version: '',
preload: false,
angular: {
detected: false,
hideDeprecation: false,
},
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
extensions: {
exposedComponents: [],
},
},
extensions: {
addedLinks: [],
addedComponents: [],
addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
},
extensions: {
addedLinks: [],
addedComponents: [],
addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
};
});
wrapper = ({ children }: { children: React.ReactNode }) => (
<PluginContextProvider meta={pluginMeta}>
@@ -496,7 +500,7 @@ describe('usePluginComponents()', () => {
});
// It can happen that core Grafana plugins (e.g. traces) reuse core components which implement extension points.
it('should not validate the extension point meta-info for core plugins', () => {
it('should not validate the extension point meta-info for core plugins', async () => {
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
const componentConfig = {
@@ -506,8 +510,12 @@ describe('usePluginComponents()', () => {
component: () => <div>Component</div>,
};
// The `AddedComponentsRegistry` is validating if the link is registered in the plugin metadata (config.apps).
config.apps[pluginId].extensions.addedComponents = [componentConfig];
// The `AddedComponentsRegistry` is validating if the link is registered in the plugin metadata.
const meta = await getAppPluginMeta(pluginId);
expect(meta).toBeDefined();
const app = { ...meta!, extensions: { ...meta!.extensions, addedComponents: [componentConfig] } };
setAppPluginMetas({ [pluginId]: app });
wrapper = ({ children }: { children: React.ReactNode }) => (
<PluginContextProvider
@@ -21,7 +21,7 @@ export function usePluginComponents<Props extends object = {}>({
}: UsePluginComponentsOptions): UsePluginComponentsResult<Props> {
const registryItems = useAddedComponentsRegistrySlice<Props>(extensionPointId);
const pluginContext = usePluginContext();
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExtensionPointPluginDependencies(extensionPointId));
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(extensionPointId, getExtensionPointPluginDependencies);
return useMemo(() => {
const { result } = validateExtensionPoint({ extensionPointId, pluginContext, isLoadingAppPlugins });
@@ -8,7 +8,8 @@ import {
PluginMeta,
PluginType,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { setAppPluginMetas } from '@grafana/runtime/internal';
import { getAppPluginMeta } from '@grafana/runtime/unstable';
import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
import * as errors from './errors';
@@ -107,31 +108,33 @@ describe('usePluginFunctions()', () => {
},
};
config.apps[pluginId] = {
id: pluginId,
path: '',
version: '',
preload: false,
angular: {
detected: false,
hideDeprecation: false,
},
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
setAppPluginMetas({
[pluginId]: {
id: pluginId,
path: '',
version: '',
preload: false,
angular: {
detected: false,
hideDeprecation: false,
},
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
extensions: {
exposedComponents: [],
},
},
extensions: {
addedLinks: [],
addedComponents: [],
addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
},
extensions: {
addedLinks: [],
addedComponents: [],
addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
};
});
wrapper = ({ children }: { children: React.ReactNode }) => (
<PluginContextProvider meta={pluginMeta}>
@@ -318,7 +321,7 @@ describe('usePluginFunctions()', () => {
});
// It can happen that core Grafana plugins (e.g. traces) reuse core components which implement extension points.
it('should not validate the extension point meta-info for core plugins', () => {
it('should not validate the extension point meta-info for core plugins', async () => {
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
const functionConfig = {
@@ -328,8 +331,12 @@ describe('usePluginFunctions()', () => {
fn: () => 'function1',
};
// The `AddedFunctionsRegistry` is validating if the function is registered in the plugin metadata (config.apps).
config.apps[pluginId].extensions.addedFunctions = [functionConfig];
// The `AddedFunctionsRegistry` is validating if the function is registered in the plugin metadata.
const meta = await getAppPluginMeta(pluginId);
expect(meta).toBeDefined();
const app = { ...meta!, extensions: { ...meta!.extensions, addedFunctions: [functionConfig] } };
setAppPluginMetas({ [pluginId]: app });
wrapper = ({ children }: { children: React.ReactNode }) => (
<PluginContextProvider
@@ -15,8 +15,7 @@ export function usePluginFunctions<Signature>({
}: UsePluginFunctionsOptions): UsePluginFunctionsResult<Signature> {
const registryItems = useAddedFunctionsRegistrySlice<Signature>(extensionPointId);
const pluginContext = usePluginContext();
const deps = getExtensionPointPluginDependencies(extensionPointId);
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(deps);
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(extensionPointId, getExtensionPointPluginDependencies);
return useMemo(() => {
const { result } = validateExtensionPoint({ extensionPointId, pluginContext, isLoadingAppPlugins });
@@ -8,7 +8,8 @@ import {
PluginMeta,
PluginType,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { setAppPluginMetas } from '@grafana/runtime/internal';
import { getAppPluginMeta } from '@grafana/runtime/unstable';
import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
import * as errors from './errors';
@@ -107,31 +108,33 @@ describe('usePluginLinks()', () => {
},
};
config.apps[pluginId] = {
id: pluginId,
path: '',
version: '',
preload: false,
angular: {
detected: false,
hideDeprecation: false,
},
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
setAppPluginMetas({
[pluginId]: {
id: pluginId,
path: '',
version: '',
preload: false,
angular: {
detected: false,
hideDeprecation: false,
},
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
extensions: {
exposedComponents: [],
},
},
extensions: {
addedLinks: [],
addedComponents: [],
addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
},
extensions: {
addedLinks: [],
addedComponents: [],
addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
};
});
wrapper = ({ children }: { children: React.ReactNode }) => (
<PluginContextProvider meta={pluginMeta}>
@@ -258,7 +261,7 @@ describe('usePluginLinks()', () => {
});
// It can happen that core Grafana plugins (e.g. traces) reuse core components which implement extension points.
it('should not validate the extension point meta-info for core plugins', () => {
it('should not validate the extension point meta-info for core plugins', async () => {
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
const linkConfig = {
@@ -269,7 +272,11 @@ describe('usePluginLinks()', () => {
};
// The `AddedLinksRegistry` is validating if the link is registered in the plugin metadata (config.apps).
config.apps[pluginId].extensions.addedLinks = [linkConfig];
const meta = await getAppPluginMeta(pluginId);
expect(meta).toBeDefined();
const app = { ...meta!, extensions: { ...meta!.extensions, addedLinks: [linkConfig] } };
setAppPluginMetas({ [pluginId]: app });
wrapper = ({ children }: { children: React.ReactNode }) => (
<PluginContextProvider
@@ -24,7 +24,7 @@ export function usePluginLinks({
}: UsePluginLinksOptions): UsePluginLinksResult {
const registryItems = useAddedLinksRegistrySlice(extensionPointId);
const pluginContext = usePluginContext();
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExtensionPointPluginDependencies(extensionPointId));
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(extensionPointId, getExtensionPointPluginDependencies);
return useMemo(() => {
const { result, pointLog } = validateExtensionPoint({
File diff suppressed because it is too large Load Diff
@@ -4,6 +4,7 @@ import * as React from 'react';
import { useAsync } from 'react-use';
import {
type AppPluginConfig,
type PluginExtensionEventHelpers,
type PluginExtensionOpenModalOptions,
isDateTime,
@@ -13,10 +14,9 @@ import {
PanelMenuItem,
PluginExtensionAddedLinkConfig,
urlUtil,
PluginExtensionPoints,
ExtensionInfo,
} from '@grafana/data';
import { reportInteraction, config, AppPluginConfig } from '@grafana/runtime';
import { reportInteraction, config } from '@grafana/runtime';
import { Modal } from '@grafana/ui';
import { appEvents } from 'app/core/app_events';
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
@@ -28,6 +28,7 @@ import {
} from 'app/types/events';
import { RestrictedGrafanaApisProvider } from '../components/restrictedGrafanaApis/RestrictedGrafanaApisProvider';
import { PreloadAppPluginsPredicate } from '../pluginPreloader';
import { ExtensionErrorBoundary } from './ExtensionErrorBoundary';
import { ExtensionsLog, log as baseLog } from './logs/log';
@@ -609,9 +610,6 @@ export function getLinkExtensionPathWithTracking(pluginId: string, path: string,
// Can be set with the `GF_DEFAULT_APP_MODE` environment variable
export const isGrafanaDevMode = () => config.buildInfo.env === 'development';
export const getAppPluginConfigs = (pluginIds: string[] = []) =>
Object.values(config.apps).filter((app) => pluginIds.includes(app.id));
export const getAppPluginIdFromExposedComponentId = (exposedComponentId: string) => {
return exposedComponentId.split('/')[0];
};
@@ -619,8 +617,11 @@ export const getAppPluginIdFromExposedComponentId = (exposedComponentId: string)
// Returns a list of app plugin ids that are registering extensions to this extension point.
// (These plugins are necessary to be loaded to use the extension point.)
// (The function also returns the plugin ids that the plugins - that extend the extension point - depend on.)
export const getExtensionPointPluginDependencies = (extensionPointId: string): string[] => {
return Object.values(config.apps)
export const getExtensionPointPluginDependencies: PreloadAppPluginsPredicate = (
apps: AppPluginConfig[],
extensionPointId: string
): string[] => {
return apps
.filter(
(app) =>
app.extensions.addedLinks.some((link) => link.targets.includes(extensionPointId)) ||
@@ -628,7 +629,7 @@ export const getExtensionPointPluginDependencies = (extensionPointId: string): s
)
.map((app) => app.id)
.reduce((acc: string[], id: string) => {
return [...acc, id, ...getAppPluginDependencies(id)];
return [...acc, id, ...getAppPluginDependencies(apps, id)];
}, []);
};
@@ -645,11 +646,14 @@ export type ExtensionPointPluginMeta = Map<
* @param extensionPointId - The id of the extension point.
* @returns A map of plugin ids and their addedComponents and addedLinks to the extension point.
*/
export const getExtensionPointPluginMeta = (extensionPointId: string): ExtensionPointPluginMeta => {
export const getExtensionPointPluginMeta = (
apps: AppPluginConfig[],
extensionPointId: string
): ExtensionPointPluginMeta => {
return new Map(
getExtensionPointPluginDependencies(extensionPointId)
getExtensionPointPluginDependencies(apps, extensionPointId)
.map((pluginId) => {
const app = config.apps[pluginId];
const app = apps.find((a) => a.id === pluginId);
// if the plugin does not exist or does not expose any components or links to the extension point, return undefined
if (
!app ||
@@ -674,19 +678,27 @@ export const getExtensionPointPluginMeta = (extensionPointId: string): Extension
// Returns a list of app plugin ids that are necessary to be loaded to use the exposed component.
// (It is first the plugin that exposes the component, and then the ones that it depends on.)
export const getExposedComponentPluginDependencies = (exposedComponentId: string) => {
export const getExposedComponentPluginDependencies: PreloadAppPluginsPredicate = (
apps: AppPluginConfig[],
exposedComponentId: string
) => {
const pluginId = getAppPluginIdFromExposedComponentId(exposedComponentId);
return [pluginId].reduce((acc: string[], pluginId: string) => {
return [...acc, pluginId, ...getAppPluginDependencies(pluginId)];
return [...acc, pluginId, ...getAppPluginDependencies(apps, pluginId)];
}, []);
};
// Returns a list of app plugin ids that are necessary to be loaded, based on the `dependencies.extensions`
// metadata field. (For example the plugins that expose components that the app depends on.)
// Heads up! This is a recursive function.
export const getAppPluginDependencies = (pluginId: string, visited: string[] = []): string[] => {
if (!config.apps[pluginId]) {
export const getAppPluginDependencies = (
apps: AppPluginConfig[],
pluginId: string,
visited: string[] = []
): string[] => {
const app = apps.find((a) => a.id === pluginId);
if (!app) {
return [];
}
@@ -695,38 +707,14 @@ export const getAppPluginDependencies = (pluginId: string, visited: string[] = [
return [];
}
const pluginIdDependencies = config.apps[pluginId].dependencies.extensions.exposedComponents.map(
getAppPluginIdFromExposedComponentId
);
const pluginIdDependencies = app.dependencies.extensions.exposedComponents.map(getAppPluginIdFromExposedComponentId);
return (
pluginIdDependencies
.reduce((acc, _pluginId) => {
return [...acc, ...getAppPluginDependencies(_pluginId, [...visited, pluginId])];
return [...acc, ...getAppPluginDependencies(apps, _pluginId, [...visited, pluginId])];
}, pluginIdDependencies)
// We don't want the plugin to "depend on itself"
.filter((id) => id !== pluginId)
);
};
// Returns a list of app plugins that has to be loaded before core Grafana could finish the initialization.
export const getAppPluginsToAwait = () => {
const pluginIds = [
// The "cloud-home-app" is registering banners once it's loaded, and this can cause a rerender in the AppChrome if it's loaded after the Grafana app init.
'cloud-home-app',
];
return Object.values(config.apps).filter((app) => pluginIds.includes(app.id));
};
// Returns a list of app plugins that has to be preloaded in parallel with the core Grafana initialization.
export const getAppPluginsToPreload = () => {
// The DashboardPanelMenu extension point is using the `getPluginExtensions()` API in scenes at the moment, which means that it cannot yet benefit from dynamic plugin loading.
const dashboardPanelMenuPluginIds = getExtensionPointPluginDependencies(PluginExtensionPoints.DashboardPanelMenu);
const awaitedPluginIds = getAppPluginsToAwait().map((app) => app.id);
const isNotAwaited = (app: AppPluginConfig) => !awaitedPluginIds.includes(app.id);
return Object.values(config.apps).filter((app) => {
return isNotAwaited(app) && (app.preload || dashboardPanelMenuPluginIds.includes(app.id));
});
};
File diff suppressed because it is too large Load Diff
@@ -10,7 +10,8 @@ import {
PluginExtensionPointPatterns,
} from '@grafana/data';
import { PluginAddedLinksConfigureFunc } from '@grafana/data/internal';
import { config, isPluginExtensionLink } from '@grafana/runtime';
import { isPluginExtensionLink } from '@grafana/runtime';
import { getAppPluginMeta } from '@grafana/runtime/unstable';
import * as errors from './errors';
import { ExtensionsLog } from './logs/log';
@@ -145,13 +146,13 @@ export const isExposedComponentDependencyMissing = (id: string, pluginContext: P
return !exposedComponentsDependencies || !exposedComponentsDependencies.includes(id);
};
export const isAddedLinkMetaInfoMissing = (
export const isAddedLinkMetaInfoMissing = async (
pluginId: string,
metaInfo: PluginExtensionAddedLinkConfig,
log: ExtensionsLog
) => {
const logPrefix = 'Could not register link extension. Reason:';
const app = config.apps[pluginId];
const app = await getAppPluginMeta(pluginId);
const pluginJsonMetaInfo = app ? app.extensions.addedLinks.filter(({ title }) => title === metaInfo.title) : null;
if (!app) {
@@ -177,13 +178,13 @@ export const isAddedLinkMetaInfoMissing = (
return false;
};
export const isAddedFunctionMetaInfoMissing = (
export const isAddedFunctionMetaInfoMissing = async (
pluginId: string,
metaInfo: PluginExtensionAddedFunctionConfig,
log: ExtensionsLog
) => {
const logPrefix = 'Could not register function extension. Reason:';
const app = config.apps[pluginId];
const app = await getAppPluginMeta(pluginId);
const pluginJsonMetaInfo = app ? app.extensions.addedFunctions.filter(({ title }) => title === metaInfo.title) : null;
if (!app) {
@@ -209,13 +210,13 @@ export const isAddedFunctionMetaInfoMissing = (
return false;
};
export const isAddedComponentMetaInfoMissing = (
export const isAddedComponentMetaInfoMissing = async (
pluginId: string,
metaInfo: PluginExtensionAddedComponentConfig,
log: ExtensionsLog
) => {
const logPrefix = 'Could not register component extension. Reason:';
const app = config.apps[pluginId];
const app = await getAppPluginMeta(pluginId);
const pluginJsonMetaInfo = app
? app.extensions.addedComponents.filter(({ title }) => title === metaInfo.title)
: null;
@@ -243,13 +244,13 @@ export const isAddedComponentMetaInfoMissing = (
return false;
};
export const isExposedComponentMetaInfoMissing = (
export const isExposedComponentMetaInfoMissing = async (
pluginId: string,
metaInfo: PluginExtensionExposedComponentConfig,
log: ExtensionsLog
) => {
const logPrefix = 'Could not register exposed component extension. Reason:';
const app = config.apps[pluginId];
const app = await getAppPluginMeta(pluginId);
const pluginJsonMetaInfo = app ? app.extensions.exposedComponents.filter(({ id }) => id === metaInfo.id) : null;
if (!app) {
+61 -5
View File
@@ -1,12 +1,15 @@
import type {
AppPluginConfig,
PluginExtensionAddedLinkConfig,
PluginExtensionExposedComponentConfig,
PluginExtensionAddedComponentConfig,
import {
type AppPluginConfig,
type PluginExtensionAddedLinkConfig,
type PluginExtensionExposedComponentConfig,
type PluginExtensionAddedComponentConfig,
PluginExtensionPoints,
} from '@grafana/data';
import { getAppPluginMetas } from '@grafana/runtime/unstable';
import { contextSrv } from 'app/core/services/context_srv';
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
import { getExtensionPointPluginDependencies } from './extensions/utils';
import { pluginImporter } from './importer/pluginImporter';
export type PluginPreloadResult = {
@@ -23,6 +26,59 @@ export const clearPreloadedPluginsCache = () => {
preloadPromises.clear();
};
function getAppPluginIdsToAwait() {
const pluginIds = [
// The "cloud-home-app" is registering banners once it's loaded, and this can cause a rerender in the AppChrome if it's loaded after the Grafana app init.
'cloud-home-app',
];
return pluginIds;
}
function isNotAwaited(app: AppPluginConfig) {
return !getAppPluginIdsToAwait().includes(app.id);
}
export async function preloadPluginsToBeAwaited() {
const apps = await getAppPluginMetas();
const awaited = getAppPluginIdsToAwait();
const filtered = apps.filter((app) => awaited.includes(app.id));
preloadPlugins(filtered);
}
export async function preloadPluginsToBePreloaded() {
const apps = await getAppPluginMetas();
// The DashboardPanelMenu extension point is using the `getPluginExtensions()` API in scenes at the moment, which means that it cannot yet benefit from dynamic plugin loading.
const dashboardPanelMenuPluginIds = getExtensionPointPluginDependencies(
apps,
PluginExtensionPoints.DashboardPanelMenu
);
const filtered = apps.filter((app) => {
return isNotAwaited(app) && (app.preload || dashboardPanelMenuPluginIds.includes(app.id));
});
preloadPlugins(filtered);
}
export type PreloadAppPluginsPredicate = (apps: AppPluginConfig[], extensionId: string) => string[];
const noop: PreloadAppPluginsPredicate = () => [];
export async function preloadPluginsWithPredicate(extensionId: string, predicate: PreloadAppPluginsPredicate = noop) {
const apps = await getAppPluginMetas();
const filteredIds = predicate(apps, extensionId);
const filtered = apps.filter((app) => filteredIds.includes(app.id));
if (!filtered.length) {
return;
}
preloadPlugins(filtered);
}
export async function preloadPlugins(apps: AppPluginConfig[] = []) {
// Create preload promises for each app, reusing existing promises if already loading
const promises = apps.map((app) => {
@@ -1,5 +1,6 @@
import { PluginType, patchArrayVectorProrotypeMethods } from '@grafana/data';
import { config } from '@grafana/runtime';
import { getAppPluginMeta } from '@grafana/runtime/unstable';
import { transformPluginSourceForCDN } from '../cdn/utils';
import { resolvePluginUrlWithCache } from '../loader/pluginInfoCache';
@@ -121,7 +122,7 @@ export function patchSandboxEnvironmentPrototype(sandboxEnvironment: SandboxEnvi
);
}
export function getPluginLoadData(pluginId: string): SandboxPluginMeta {
export async function getPluginLoadData(pluginId: string): Promise<SandboxPluginMeta> {
// find it in datasources
for (const datasource of Object.values(config.datasources)) {
if (datasource.type === pluginId) {
@@ -138,16 +139,15 @@ export function getPluginLoadData(pluginId: string): SandboxPluginMeta {
//find it in apps
//the information inside the apps object is more limited
for (const app of Object.values(config.apps)) {
if (app.id === pluginId) {
return {
id: pluginId,
type: PluginType.app,
module: app.path,
moduleHash: app.moduleHash,
};
}
const app = await getAppPluginMeta(pluginId);
if (!app) {
throw new Error(`Could not find plugin ${pluginId}`);
}
throw new Error(`Could not find plugin ${pluginId}`);
return {
id: pluginId,
type: PluginType.app,
module: app.path,
moduleHash: app.moduleHash,
};
}
@@ -31,7 +31,7 @@ const pluginLogCache: Record<string, boolean> = {};
export async function importPluginModuleInSandbox({ pluginId }: { pluginId: string }): Promise<System.Module> {
patchWebAPIs();
try {
const pluginMeta = getPluginLoadData(pluginId);
const pluginMeta = await getPluginLoadData(pluginId);
if (!pluginImportCache.has(pluginId)) {
pluginImportCache.set(pluginId, doImportPluginModuleInSandbox(pluginMeta));
}
@@ -33,6 +33,11 @@ const getSummaryColumns = () => [
header: 'Unchanged',
cell: ({ row: { original: item } }: SummaryCell) => item.noop?.toString() || '-',
},
{
id: 'warnings',
header: 'Warnings',
cell: ({ row: { original: item } }: SummaryCell) => item.warning?.toString() || '-',
},
{
id: 'errors',
header: 'Errors',
@@ -911,7 +911,7 @@ const traceSubFrame = (
subFrame.add(transformSpanToTraceData(span, spanSet, trace));
});
return subFrame;
return toDataFrame(subFrame);
};
interface TraceTableData {
+17 -4
View File
@@ -3739,6 +3739,10 @@
"clear": "Vymazat vyhledávání a filtry",
"text": "Nebyly nalezeny žádné výsledky pro váš dotaz"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_one": "",
@@ -5992,13 +5996,25 @@
"title-error-loading-dashboard": "Chyba při načítání nástěnky"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "Upravit panel",
"view-panel": "Zobrazit panel"
},
"title": {
"dashboard": "Nástěnka",
"discard-changes-to-dashboard": "Zahodit změny nástěnky?"
"discard-changes-to-dashboard": "Zahodit změny nástěnky?",
"unsaved-changes-question": ""
}
},
"dashboard-scene-page-state-manager": {
@@ -10798,7 +10814,6 @@
"title": "Nové"
},
"new-dashboard": {
"empty-title": "",
"title": "Nová nástěnka"
},
"new-folder": {
@@ -11958,7 +11973,6 @@
"title-setting-connection-could-cause-temporary-outage": "Nastavení tohoto připojení může způsobit dočasný výpadek"
},
"getting-started-page": {
"header": "Zajišťování",
"subtitle-provisioning-feature": "Zobrazujte a spravujte vazby zajištění"
},
"git": {
@@ -12730,7 +12744,6 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "Importovat",
"new": "Nové",
"new-dashboard": "Nová nástěnka",
+17 -4
View File
@@ -3707,6 +3707,10 @@
"clear": "Suche und Filter löschen",
"text": "Keine Ergebnisse für deine Abfrage gefunden"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_one": "",
@@ -5950,13 +5954,25 @@
"title-error-loading-dashboard": "Fehler beim Laden des Dashboards"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "Panel bearbeiten",
"view-panel": "Panel anzeigen"
},
"title": {
"dashboard": "Dashboard",
"discard-changes-to-dashboard": "Änderungen am Dashboard verwerfen?"
"discard-changes-to-dashboard": "Änderungen am Dashboard verwerfen?",
"unsaved-changes-question": ""
}
},
"dashboard-scene-page-state-manager": {
@@ -10712,7 +10728,6 @@
"title": "Neu"
},
"new-dashboard": {
"empty-title": "",
"title": "Neues Dashboard"
},
"new-folder": {
@@ -11856,7 +11871,6 @@
"title-setting-connection-could-cause-temporary-outage": "Das Einrichten dieser Verbindung kann zu einem vorübergehenden Ausfall führen"
},
"getting-started-page": {
"header": "Bereitstellung",
"subtitle-provisioning-feature": "Sehen und verwalten Sie Ihre Bereitstellungsverbindungen"
},
"git": {
@@ -12622,7 +12636,6 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "Importieren",
"new": "Neu",
"new-dashboard": "Neues Dashboard",
+17 -4
View File
@@ -3707,6 +3707,10 @@
"clear": "Borrar la búsqueda y los filtros",
"text": "No se han encontrado resultados para tu consulta"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_one": "",
@@ -5950,13 +5954,25 @@
"title-error-loading-dashboard": "Error al cargar el panel de control"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "Editar panel",
"view-panel": "Ver panel"
},
"title": {
"dashboard": "Panel de control",
"discard-changes-to-dashboard": "¿Descartar los cambios en el dashboard?"
"discard-changes-to-dashboard": "¿Descartar los cambios en el dashboard?",
"unsaved-changes-question": ""
}
},
"dashboard-scene-page-state-manager": {
@@ -10712,7 +10728,6 @@
"title": "Nuevo"
},
"new-dashboard": {
"empty-title": "",
"title": "Nuevo panel de control"
},
"new-folder": {
@@ -11856,7 +11871,6 @@
"title-setting-connection-could-cause-temporary-outage": "Configurar esta conexión podría causar una interrupción temporal"
},
"getting-started-page": {
"header": "Aprovisionamiento",
"subtitle-provisioning-feature": "Ver y gestionar tus conexiones de aprovisionamiento"
},
"git": {
@@ -12622,7 +12636,6 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "Importar",
"new": "Nuevo",
"new-dashboard": "Nuevo panel de control",
+17 -4
View File
@@ -3707,6 +3707,10 @@
"clear": "Effacer la recherche et les filtres",
"text": "Aucun résultat n'a été trouvé pour votre requête"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_one": "",
@@ -5950,13 +5954,25 @@
"title-error-loading-dashboard": "Erreur lors du chargement du tableau de bord"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "Modifier le panneau",
"view-panel": "Afficher le panneau"
},
"title": {
"dashboard": "Tableau de bord",
"discard-changes-to-dashboard": "Abandonner les modifications apportées au tableau de bord ?"
"discard-changes-to-dashboard": "Abandonner les modifications apportées au tableau de bord ?",
"unsaved-changes-question": ""
}
},
"dashboard-scene-page-state-manager": {
@@ -10712,7 +10728,6 @@
"title": "Nouveau"
},
"new-dashboard": {
"empty-title": "",
"title": "Nouveau tableau de bord"
},
"new-folder": {
@@ -11856,7 +11871,6 @@
"title-setting-connection-could-cause-temporary-outage": "La configuration de cette connexion peut entraîner une interruption temporaire"
},
"getting-started-page": {
"header": "Mise en service",
"subtitle-provisioning-feature": "Afficher et gérer vos connexions de mise en service"
},
"git": {
@@ -12622,7 +12636,6 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "Importer",
"new": "Nouveau",
"new-dashboard": "Nouveau tableau de bord",
+17 -4
View File
@@ -3707,6 +3707,10 @@
"clear": "Keresés és szűrők törlése",
"text": "Nincs találat a lekérdezésre"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_one": "",
@@ -5950,13 +5954,25 @@
"title-error-loading-dashboard": "Hiba történt az irányítópult betöltésekor"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "Panel szerkesztése",
"view-panel": "Panel megtekintése"
},
"title": {
"dashboard": "Irányítópult",
"discard-changes-to-dashboard": "Elveti az irányítópult módosításait?"
"discard-changes-to-dashboard": "Elveti az irányítópult módosításait?",
"unsaved-changes-question": ""
}
},
"dashboard-scene-page-state-manager": {
@@ -10712,7 +10728,6 @@
"title": "Új"
},
"new-dashboard": {
"empty-title": "",
"title": "Új irányítópult"
},
"new-folder": {
@@ -11856,7 +11871,6 @@
"title-setting-connection-could-cause-temporary-outage": "A kapcsolat létrehozása ideiglenes üzemszünetet okozhat"
},
"getting-started-page": {
"header": "Kiépítés",
"subtitle-provisioning-feature": "Kiépítési kapcsolatok megtekintése és kezelése"
},
"git": {
@@ -12622,7 +12636,6 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "Importálás",
"new": "Új",
"new-dashboard": "Új irányítópult",
+17 -4
View File
@@ -3691,6 +3691,10 @@
"clear": "Hapus pencarian dan filter",
"text": "Hasil untuk kueri Anda tidak ditemukan"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_other": "",
@@ -5929,13 +5933,25 @@
"title-error-loading-dashboard": "Kesalahan saat memuat dasbor"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "Edit panel",
"view-panel": "Lihat panel"
},
"title": {
"dashboard": "Dasbor",
"discard-changes-to-dashboard": "Batalkan perubahan ke dasbor?"
"discard-changes-to-dashboard": "Batalkan perubahan ke dasbor?",
"unsaved-changes-question": ""
}
},
"dashboard-scene-page-state-manager": {
@@ -10669,7 +10685,6 @@
"title": "Baru"
},
"new-dashboard": {
"empty-title": "",
"title": "Dasbor baru"
},
"new-folder": {
@@ -11805,7 +11820,6 @@
"title-setting-connection-could-cause-temporary-outage": "Mengatur koneksi ini dapat menyebabkan pemadaman sementara"
},
"getting-started-page": {
"header": "Penyediaan",
"subtitle-provisioning-feature": "Lihat dan kelola koneksi penyediaan Anda"
},
"git": {
@@ -12568,7 +12582,6 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "Impor",
"new": "Baru",
"new-dashboard": "Dasbor baru",
+17 -4
View File
@@ -3707,6 +3707,10 @@
"clear": "Cancella ricerca e filtri",
"text": "Nessun risultato trovato per la ricerca"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_one": "",
@@ -5950,13 +5954,25 @@
"title-error-loading-dashboard": "Errore durante il caricamento del dashboard"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "Modifica pannello",
"view-panel": "Visualizza pannello"
},
"title": {
"dashboard": "Dashboard",
"discard-changes-to-dashboard": "Annullare le modifiche alla dashboard?"
"discard-changes-to-dashboard": "Annullare le modifiche alla dashboard?",
"unsaved-changes-question": ""
}
},
"dashboard-scene-page-state-manager": {
@@ -10712,7 +10728,6 @@
"title": "Nuovo"
},
"new-dashboard": {
"empty-title": "",
"title": "Nuovo dashboard"
},
"new-folder": {
@@ -11856,7 +11871,6 @@
"title-setting-connection-could-cause-temporary-outage": "La configurazione di questa connessione potrebbe causare un'interruzione temporanea"
},
"getting-started-page": {
"header": "Provisioning",
"subtitle-provisioning-feature": "Visualizza e gestisci le connessioni di provisioning"
},
"git": {
@@ -12622,7 +12636,6 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "Importa",
"new": "Nuovo",
"new-dashboard": "Nuovo dashboard",
+17 -4
View File
@@ -3691,6 +3691,10 @@
"clear": "検索とフィルタをクリア",
"text": "クエリに一致する結果が見つかりませんでした。"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_other": "",
@@ -5929,13 +5933,25 @@
"title-error-loading-dashboard": "ダッシュボードの読み込み中にエラーが発生しました"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "パネルを編集",
"view-panel": "パネルを表示"
},
"title": {
"dashboard": "ダッシュボード",
"discard-changes-to-dashboard": "ダッシュボードへの変更を破棄しますか?"
"discard-changes-to-dashboard": "ダッシュボードへの変更を破棄しますか?",
"unsaved-changes-question": ""
}
},
"dashboard-scene-page-state-manager": {
@@ -10669,7 +10685,6 @@
"title": "新規"
},
"new-dashboard": {
"empty-title": "",
"title": "新しいダッシュボード"
},
"new-folder": {
@@ -11805,7 +11820,6 @@
"title-setting-connection-could-cause-temporary-outage": "この接続設定を行うことで、一時的に停止する可能性があります"
},
"getting-started-page": {
"header": "プロビジョニング",
"subtitle-provisioning-feature": "プロビジョニング接続を表示・管理"
},
"git": {
@@ -12568,7 +12582,6 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "インポート",
"new": "新規",
"new-dashboard": "新しいダッシュボード",
+17 -4
View File
@@ -3691,6 +3691,10 @@
"clear": "검색 및 필터 초기화",
"text": "쿼리에 대해 찾은 결과 없음"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_other": "",
@@ -5929,13 +5933,25 @@
"title-error-loading-dashboard": "대시보드 로딩 중 오류 발생"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "패널 편집",
"view-panel": "패널 보기"
},
"title": {
"dashboard": "대시보드",
"discard-changes-to-dashboard": "대시보드 변경 사항을 취소하시겠어요?"
"discard-changes-to-dashboard": "대시보드 변경 사항을 취소하시겠어요?",
"unsaved-changes-question": ""
}
},
"dashboard-scene-page-state-manager": {
@@ -10669,7 +10685,6 @@
"title": "신규"
},
"new-dashboard": {
"empty-title": "",
"title": "새 대시보드"
},
"new-folder": {
@@ -11805,7 +11820,6 @@
"title-setting-connection-could-cause-temporary-outage": "이 연결을 설정하면 일시적인 중단이 발생할 수 있습니다"
},
"getting-started-page": {
"header": "프로비저닝",
"subtitle-provisioning-feature": "프로비저닝 연결 보기 및 관리"
},
"git": {
@@ -12568,7 +12582,6 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "가져오기",
"new": "신규",
"new-dashboard": "새 대시보드",
+17 -4
View File
@@ -3707,6 +3707,10 @@
"clear": "Zoekopdracht en filters wissen",
"text": "Geen resultaten gevonden voor je zoekopdracht"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_one": "",
@@ -5950,13 +5954,25 @@
"title-error-loading-dashboard": "Er is een fout opgetreden bij het laden van het dashboard"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "Paneel bewerken",
"view-panel": "Paneel bekijken"
},
"title": {
"dashboard": "Dashboard",
"discard-changes-to-dashboard": "Wijzigingen in dashboard verwerpen?"
"discard-changes-to-dashboard": "Wijzigingen in dashboard verwerpen?",
"unsaved-changes-question": ""
}
},
"dashboard-scene-page-state-manager": {
@@ -10712,7 +10728,6 @@
"title": "Nieuw"
},
"new-dashboard": {
"empty-title": "",
"title": "Nieuw dashboard"
},
"new-folder": {
@@ -11856,7 +11871,6 @@
"title-setting-connection-could-cause-temporary-outage": "Het opzetten van deze verbinding kan een tijdelijke storing veroorzaken"
},
"getting-started-page": {
"header": "Provisioning",
"subtitle-provisioning-feature": "Je provisioningverbindingen bekijken en beheren"
},
"git": {
@@ -12622,7 +12636,6 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "Importeren",
"new": "Nieuw",
"new-dashboard": "Nieuw dashboard",
+17 -4
View File
@@ -3739,6 +3739,10 @@
"clear": "Wyczyść wyszukiwanie i filtry",
"text": "Nie znaleziono wyników dla tego zapytania"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_one": "",
@@ -5992,13 +5996,25 @@
"title-error-loading-dashboard": "Błąd wczytywania pulpitu"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "Edytuj panel",
"view-panel": "Wyświetl panel"
},
"title": {
"dashboard": "Pulpit",
"discard-changes-to-dashboard": "Odrzucić zmiany dotyczące pulpitu?"
"discard-changes-to-dashboard": "Odrzucić zmiany dotyczące pulpitu?",
"unsaved-changes-question": ""
}
},
"dashboard-scene-page-state-manager": {
@@ -10798,7 +10814,6 @@
"title": "Nowy"
},
"new-dashboard": {
"empty-title": "",
"title": "Nowy pulpit"
},
"new-folder": {
@@ -11958,7 +11973,6 @@
"title-setting-connection-could-cause-temporary-outage": "Skonfigurowanie tego połączenia może spowodować tymczasową niedostępność"
},
"getting-started-page": {
"header": "Konfiguracja",
"subtitle-provisioning-feature": "Wyświetlaj połączenia aprowizacyjne i nimi zarządzaj"
},
"git": {
@@ -12730,7 +12744,6 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "Importuj",
"new": "Nowy",
"new-dashboard": "Nowy pulpit",
+17 -4
View File
@@ -3707,6 +3707,10 @@
"clear": "Limpar busca e filtros",
"text": "Nenhum resultado encontrado para sua consulta"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_one": "",
@@ -5950,13 +5954,25 @@
"title-error-loading-dashboard": "Erro ao carregar o painel de controle"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "Editar painel",
"view-panel": "Visualizar painel"
},
"title": {
"dashboard": "Painel de controle",
"discard-changes-to-dashboard": "Deseja descartar as alterações no painel?"
"discard-changes-to-dashboard": "Deseja descartar as alterações no painel?",
"unsaved-changes-question": ""
}
},
"dashboard-scene-page-state-manager": {
@@ -10712,7 +10728,6 @@
"title": "Novo"
},
"new-dashboard": {
"empty-title": "",
"title": "Novo painel de controle"
},
"new-folder": {
@@ -11856,7 +11871,6 @@
"title-setting-connection-could-cause-temporary-outage": "Estabelecer esta conexão pode causar uma interrupção temporária"
},
"getting-started-page": {
"header": "Aprovisionamento",
"subtitle-provisioning-feature": "Visualize e gerencie suas conexões de provisionamento"
},
"git": {
@@ -12622,7 +12636,6 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "Importar",
"new": "Novo",
"new-dashboard": "Novo painel de controle",
+17 -4
View File
@@ -3707,6 +3707,10 @@
"clear": "Limpar a pesquisa e os filtros",
"text": "Não foram encontrados resultados para a sua consulta"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_one": "",
@@ -5950,13 +5954,25 @@
"title-error-loading-dashboard": "Erro ao carregar o painel de controlo"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "Editar painel",
"view-panel": "Visualizar painel"
},
"title": {
"dashboard": "Painel de controlo",
"discard-changes-to-dashboard": "Rejeitar alterações no painel de controlo?"
"discard-changes-to-dashboard": "Rejeitar alterações no painel de controlo?",
"unsaved-changes-question": ""
}
},
"dashboard-scene-page-state-manager": {
@@ -10712,7 +10728,6 @@
"title": "Novo"
},
"new-dashboard": {
"empty-title": "",
"title": "Novo painel de controlo"
},
"new-folder": {
@@ -11856,7 +11871,6 @@
"title-setting-connection-could-cause-temporary-outage": "Configurar esta ligação pode causar uma interrupção temporária"
},
"getting-started-page": {
"header": "Provisionamento",
"subtitle-provisioning-feature": "Ver e gerir as suas ligações de provisionamento"
},
"git": {
@@ -12622,7 +12636,6 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "Importar",
"new": "Novo",
"new-dashboard": "Novo painel de controlo",
+17 -4
View File
@@ -3739,6 +3739,10 @@
"clear": "Очистить поиск и фильтры",
"text": "По вашему запросу ничего не найдено"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_one": "",
@@ -5992,13 +5996,25 @@
"title-error-loading-dashboard": "Ошибка при загрузке дашборда"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "Редактировать панель",
"view-panel": "Просмотр панели"
},
"title": {
"dashboard": "Дашборд",
"discard-changes-to-dashboard": "Отменить изменения на дашборде?"
"discard-changes-to-dashboard": "Отменить изменения на дашборде?",
"unsaved-changes-question": ""
}
},
"dashboard-scene-page-state-manager": {
@@ -10798,7 +10814,6 @@
"title": "Новые элементы"
},
"new-dashboard": {
"empty-title": "",
"title": "Новый дашборд"
},
"new-folder": {
@@ -11958,7 +11973,6 @@
"title-setting-connection-could-cause-temporary-outage": "Настройка этого подключения может привести к временному сбою"
},
"getting-started-page": {
"header": "Подготовка к работе",
"subtitle-provisioning-feature": "Просмотр подключений для подготовки и управлением ими"
},
"git": {
@@ -12730,7 +12744,6 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "Импорт",
"new": "Новые элементы",
"new-dashboard": "Новый дашборд",
+17 -4
View File
@@ -3707,6 +3707,10 @@
"clear": "Rensa sökning och filter",
"text": "Inga resultat hittades för din fråga"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_one": "",
@@ -5950,13 +5954,25 @@
"title-error-loading-dashboard": "Fel vid laddning av instrumentpanel"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "Redigera panel",
"view-panel": "Visa panel"
},
"title": {
"dashboard": "Instrumentpanel",
"discard-changes-to-dashboard": "Kassera ändringar i instrumentpanelen?"
"discard-changes-to-dashboard": "Kassera ändringar i instrumentpanelen?",
"unsaved-changes-question": ""
}
},
"dashboard-scene-page-state-manager": {
@@ -10712,7 +10728,6 @@
"title": "Nyhet"
},
"new-dashboard": {
"empty-title": "",
"title": "Ny instrumentpanel"
},
"new-folder": {
@@ -11856,7 +11871,6 @@
"title-setting-connection-could-cause-temporary-outage": "Konfiguration av den här anslutningen kan orsaka ett tillfälligt avbrott"
},
"getting-started-page": {
"header": "Provisionering",
"subtitle-provisioning-feature": "Visa och hantera dina provisioneringsanslutningar"
},
"git": {
@@ -12622,7 +12636,6 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "Importera",
"new": "Nyhet",
"new-dashboard": "Ny instrumentpanel",
+17 -4
View File
@@ -3707,6 +3707,10 @@
"clear": "Aramayı ve filtreleri temizle",
"text": "Sorgunuz için sonuç bulunamadı"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_one": "",
@@ -5950,13 +5954,25 @@
"title-error-loading-dashboard": "Pano yüklenirken hata oluştu"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "Paneli düzenle",
"view-panel": "Paneli görüntüle"
},
"title": {
"dashboard": "Pano",
"discard-changes-to-dashboard": "Panodaki değişiklikler silinsin mi?"
"discard-changes-to-dashboard": "Panodaki değişiklikler silinsin mi?",
"unsaved-changes-question": ""
}
},
"dashboard-scene-page-state-manager": {
@@ -10712,7 +10728,6 @@
"title": "Yeni"
},
"new-dashboard": {
"empty-title": "",
"title": "Yeni pano"
},
"new-folder": {
@@ -11856,7 +11871,6 @@
"title-setting-connection-could-cause-temporary-outage": "Bu bağlantıyı kurmak geçici bir kesintiye neden olabilir"
},
"getting-started-page": {
"header": "Sağlama",
"subtitle-provisioning-feature": "Sağlama bağlantılarınızı görüntüleyin ve yönetin"
},
"git": {
@@ -12622,7 +12636,6 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "İçe aktar",
"new": "Yeni",
"new-dashboard": "Yeni pano",
+17 -4
View File
@@ -3691,6 +3691,10 @@
"clear": "清除搜索和筛选条件",
"text": "未找到与您的查询相关的结果"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_other": "",
@@ -5929,13 +5933,25 @@
"title-error-loading-dashboard": "加载数据面板时出错"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "编辑面板",
"view-panel": "查看面板"
},
"title": {
"dashboard": "仪表板",
"discard-changes-to-dashboard": "放弃对数据面板的更改?"
"discard-changes-to-dashboard": "放弃对数据面板的更改?",
"unsaved-changes-question": ""
}
},
"dashboard-scene-page-state-manager": {
@@ -10669,7 +10685,6 @@
"title": "新建"
},
"new-dashboard": {
"empty-title": "",
"title": "新建仪表板"
},
"new-folder": {
@@ -11805,7 +11820,6 @@
"title-setting-connection-could-cause-temporary-outage": "设置此连接可能会导致暂时中断"
},
"getting-started-page": {
"header": "配置",
"subtitle-provisioning-feature": "查看和管理您的预配连接"
},
"git": {
@@ -12568,7 +12582,6 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "导入",
"new": "新建",
"new-dashboard": "新建仪表板",
+17 -4
View File
@@ -3691,6 +3691,10 @@
"clear": "清除搜尋和篩選條件",
"text": "未找到您的查詢結果"
},
"recently-viewed": {
"empty": "",
"title": ""
},
"restore": {
"success": "",
"all-failed_other": "",
@@ -5929,13 +5933,25 @@
"title-error-loading-dashboard": "載入控制面板發生錯誤"
},
"dashboard-scene": {
"modal": {
"cancel": "",
"discard": "",
"save": "",
"text": {
"save-changes-question": ""
},
"title": {
"unsaved-changes": ""
}
},
"text": {
"edit-panel": "編輯面板",
"view-panel": "檢視面板"
},
"title": {
"dashboard": "儀表板",
"discard-changes-to-dashboard": "要捨棄儀表板的變更嗎?"
"discard-changes-to-dashboard": "要捨棄儀表板的變更嗎?",
"unsaved-changes-question": ""
}
},
"dashboard-scene-page-state-manager": {
@@ -10669,7 +10685,6 @@
"title": "新"
},
"new-dashboard": {
"empty-title": "",
"title": "新儀表板"
},
"new-folder": {
@@ -11805,7 +11820,6 @@
"title-setting-connection-could-cause-temporary-outage": "設定此連線可能會導致暫時中斷"
},
"getting-started-page": {
"header": "佈建",
"subtitle-provisioning-feature": "檢視及管理您的佈建連線"
},
"git": {
@@ -12568,7 +12582,6 @@
}
},
"dashboard-actions": {
"empty-dashboard": "",
"import": "匯入",
"new": "新",
"new-dashboard": "新儀表板",