Compare commits

..

17 Commits

Author SHA1 Message Date
Alexander Zobnin d7bd7f4f72 Zanzana: Add tests for folder action sets 2025-12-15 13:48:19 +01:00
Gonzalo Trigueros Manzanas c47c360fd9 Provisioning: update progress warning test cause it depended on a non… (#115332)
provisioning: update progress warning test cause it depended on a non-deterministic order.
2025-12-15 12:26:18 +00:00
Oscar Kilhed 62cab8bd63 V2 Schema: Restore inspect panel json editing workflow, for v2 (#115227)
* Restore inspect panel json editing workflow, for v2

* add reporting

* add validation testing

* update the test to replicate

* update localization
2025-12-15 13:15:51 +01:00
Oscar Kilhed 1e031db607 Dynamic dashboards: Hide hidden elements in outline in view mode (#115249)
hide hidden elements in outline in view mode
2025-12-15 12:28:28 +01:00
Marc M. 172f1fb974 DynamicDashboards: Fix to allow panels with empty titles to be dragged (#115274)
DynamicDashboards: Fix panels with no titles can't be dragged
2025-12-15 11:55:16 +01:00
Jean-Philippe Quéméner a716549f36 fix(dashboards): return right token for version api (#115313) 2025-12-15 11:23:18 +01:00
Tobias Skarhed e5c1de390d Scopes: Update ScopeNavigation type (#115312)
Update scope types
2025-12-15 11:13:35 +01:00
Marc M. 20f17d72c3 DynamicDashboards: Add background (#115273) 2025-12-15 11:05:56 +01:00
Marc M. a3d7bd8dca DynamicDashboards: In view mode, hide config button when panel has not been configured (#115261) 2025-12-15 11:05:36 +01:00
Robert Horvath 074e8ce128 Chore: fix grafana 12 release and support dates (#115235)
fix release and support dates of grafana 12.3.x and 12.4.x
2025-12-15 10:59:24 +01:00
Joe Elliott 4149767391 Tempo: Correctly escape/unescape tag when looking for tag values (#114275)
* Correctly escape/unescape tag

Signed-off-by: Joe Elliott <number101010@gmail.com>

* changelog

Signed-off-by: Joe Elliott <number101010@gmail.com>

* Revert "changelog"

This reverts commit e0cde18994c67fbdd601514d2f930798b0ae76c6.

---------

Signed-off-by: Joe Elliott <number101010@gmail.com>
2025-12-15 10:41:24 +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
55 changed files with 1289 additions and 545 deletions
@@ -211,6 +211,12 @@ type ScopeNavigationSpec struct {
Scope string `json:"scope"`
// Used to navigate to a sub-scope of the main scope. URL will not be used if this is set.
SubScope string `json:"subScope,omitempty"`
// Preload the subscope children, as soon as the ScopeNavigation is loaded.
PreLoadSubScopeChildren bool `json:"preLoadSubScopeChildren,omitempty"`
// Expands to display the subscope children when the ScopeNavigation is loaded.
ExpandOnLoad bool `json:"expandOnLoad,omitempty"`
// Makes the subscope not selectable, only serving as a way to build the tree.
DisableSubScopeSelection bool `json:"disableSubScopeSelection,omitempty"`
}
// Type of the item.
@@ -642,6 +642,27 @@ func schema_pkg_apis_scope_v0alpha1_ScopeNavigationSpec(ref common.ReferenceCall
Format: "",
},
},
"preLoadSubScopeChildren": {
SchemaProps: spec.SchemaProps{
Description: "Preload the subscope children, as soon as the ScopeNavigation is loaded.",
Type: []string{"boolean"},
Format: "",
},
},
"expandOnLoad": {
SchemaProps: spec.SchemaProps{
Description: "Expands to display the subscope children when the ScopeNavigation is loaded.",
Type: []string{"boolean"},
Format: "",
},
},
"disableSubScopeSelection": {
SchemaProps: spec.SchemaProps{
Description: "Makes the subscope not selectable, only serving as a way to build the tree.",
Type: []string{"boolean"},
Format: "",
},
},
},
Required: []string{"url", "scope"},
},
@@ -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)
@@ -107,8 +107,8 @@ Here is an overview of version support through 2026:
| 12.0.x | May 5, 2025 | February 5, 2026 | Patch Support |
| 12.1.x | July 22, 2025 | April 22, 2026 | Patch Support |
| 12.2.x | September 23, 2025 | June 23, 2026 | Patch Support |
| 12.3.x | November 18, 2025 | August 18, 2026 | Yet to be released |
| 12.4.x (Last minor of 12) | February 24, 2026 | November 24, 2026 | Yet to be released |
| 12.3.x | November 19, 2025 | August 19, 2026 | Patch Support |
| 12.4.x (Last minor of 12) | February 24, 2026 | May 24, 2027 | Yet to be released |
| 13.0.0 | TBD | TBD | Yet to be released |
## How are these versions supported?
@@ -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,
-10
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
@@ -1835,11 +1830,6 @@
"count": 1
}
},
"public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx": {
"no-restricted-syntax": {
"count": 1
}
},
"public/app/features/dashboard-scene/pages/DashboardScenePage.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 2
@@ -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',
@@ -16,17 +16,22 @@ interface Props {
title?: string;
offset?: number;
dragClass?: string;
onDragStart?: (event: React.PointerEvent<HTMLDivElement>) => void;
onOpenMenu?: () => void;
}
export function HoverWidget({ menu, title, dragClass, children, offset = -32, onOpenMenu }: Props) {
export function HoverWidget({ menu, title, dragClass, children, offset = -32, onOpenMenu, onDragStart }: Props) {
const styles = useStyles2(getStyles);
const draggableRef = useRef<HTMLDivElement>(null);
const selectors = e2eSelectors.components.Panels.Panel.HoverWidget;
// Capture the pointer to keep the widget visible while dragging
const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
draggableRef.current?.setPointerCapture(e.pointerId);
}, []);
const onPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
draggableRef.current?.setPointerCapture(e.pointerId);
onDragStart?.(e);
},
[onDragStart]
);
const onPointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
draggableRef.current?.releasePointerCapture(e.pointerId);
@@ -384,6 +384,7 @@ export function PanelChrome({
menu={menu}
title={typeof title === 'string' ? title : undefined}
dragClass={dragClass}
onDragStart={onDragStart}
offset={hoverHeaderOffset}
onOpenMenu={onOpenMenu}
>
@@ -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 ---------------------------- */
@@ -154,9 +154,12 @@ func TestJobProgressRecorderWarningStatus(t *testing.T) {
// Verify the final status includes warnings
require.NotNil(t, finalStatus.Warnings)
assert.Len(t, finalStatus.Warnings, 3)
assert.Contains(t, finalStatus.Warnings[0], "deprecated API used")
assert.Contains(t, finalStatus.Warnings[1], "missing optional field")
assert.Contains(t, finalStatus.Warnings[2], "validation warning")
expectedWarnings := []string{
"deprecated API used (file: dashboards/test.json, name: test-resource, action: updated)",
"missing optional field (file: dashboards/test2.json, name: test-resource-2, action: created)",
"validation warning (file: datasources/test.yaml, name: test-resource-3, action: created)",
}
assert.ElementsMatch(t, finalStatus.Warnings, expectedWarnings)
// Verify the state is set to Warning
assert.Equal(t, provisioning.JobStateWarning, finalStatus.State)
@@ -224,4 +224,46 @@ func testCheck(t *testing.T, server *Server) {
require.NoError(t, err)
assert.Equal(t, true, res.GetAllowed())
})
t.Run("user:19 should be able to view folder 4 and all subfolders", func(t *testing.T) {
res, err := server.Check(newContextWithNamespace(), newReq("user:19", utils.VerbGet, folderGroup, folderResource, "", "", "4"))
require.NoError(t, err)
assert.True(t, res.GetAllowed())
res, err = server.Check(newContextWithNamespace(), newReq("user:19", utils.VerbGet, folderGroup, folderResource, "", "", "5"))
require.NoError(t, err)
assert.True(t, res.GetAllowed())
res, err = server.Check(newContextWithNamespace(), newReq("user:19", utils.VerbGet, folderGroup, folderResource, "", "", "6"))
require.NoError(t, err)
assert.True(t, res.GetAllowed())
})
t.Run("user:19 should be able to edit folder 4 and all subfolders", func(t *testing.T) {
res, err := server.Check(newContextWithNamespace(), newReq("user:19", utils.VerbDelete, folderGroup, folderResource, "", "", "4"))
require.NoError(t, err)
assert.True(t, res.GetAllowed())
res, err = server.Check(newContextWithNamespace(), newReq("user:19", utils.VerbDelete, folderGroup, folderResource, "", "", "5"))
require.NoError(t, err)
assert.True(t, res.GetAllowed())
res, err = server.Check(newContextWithNamespace(), newReq("user:19", utils.VerbDelete, folderGroup, folderResource, "", "", "6"))
require.NoError(t, err)
assert.True(t, res.GetAllowed())
})
t.Run("user:20 should be able to view folder 4 and all subfolders", func(t *testing.T) {
res, err := server.Check(newContextWithNamespace(), newReq("user:20", utils.VerbGet, folderGroup, folderResource, "", "", "4"))
require.NoError(t, err)
assert.True(t, res.GetAllowed())
res, err = server.Check(newContextWithNamespace(), newReq("user:20", utils.VerbGet, folderGroup, folderResource, "", "", "5"))
require.NoError(t, err)
assert.True(t, res.GetAllowed())
res, err = server.Check(newContextWithNamespace(), newReq("user:20", utils.VerbGet, folderGroup, folderResource, "", "", "6"))
require.NoError(t, err)
assert.True(t, res.GetAllowed())
})
}
@@ -73,6 +73,8 @@ func setup(t *testing.T, srv *Server) *Server {
common.NewFolderTuple("user:17", common.RelationSetView, "4"),
common.NewFolderTuple("user:18", common.RelationCreate, "general"),
common.NewFolderResourceTuple("user:18", common.RelationCreate, dashboardGroup, dashboardResource, "", "general"),
common.NewFolderTuple("user:19", common.RelationSetAdmin, "4"),
common.NewFolderTuple("user:20", common.RelationSetEdit, "4"),
}
return setupOpenFGADatabase(t, srv, tuples)
@@ -274,6 +274,11 @@ func (s *Service) listDashboardVersionsThroughK8s(
continueToken = tempOut.GetContinue()
}
// Update the continue token on the response to reflect the actual position after all fetched items.
// Without this, the response would return the token from the first fetch, causing duplicate items
// on subsequent pages when multiple fetches were needed to fill the requested limit.
out.SetContinue(continueToken)
return out, nil
}
@@ -268,6 +268,58 @@ func TestListDashboardVersions(t *testing.T) {
}}}, res)
})
t.Run("List returns continue token when first fetch satisfies limit with more pages", func(t *testing.T) {
dashboardService := dashboards.NewFakeDashboardService(t)
dashboardVersionService := Service{dashSvc: dashboardService, features: featuremgmt.WithFeatures()}
mockCli := new(client.MockK8sHandler)
dashboardVersionService.k8sclient = mockCli
dashboardVersionService.features = featuremgmt.WithFeatures()
dashboardService.On("GetDashboardUIDByID", mock.Anything,
mock.AnythingOfType("*dashboards.GetDashboardRefByIDQuery")).
Return(&dashboards.DashboardRef{UID: "uid"}, nil)
query := dashver.ListDashboardVersionsQuery{DashboardID: 42, Limit: 2}
mockCli.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{}, nil)
firstPage := &unstructured.UnstructuredList{
Items: []unstructured.Unstructured{
{Object: map[string]any{
"metadata": map[string]any{
"name": "uid",
"resourceVersion": "11",
"generation": int64(4),
"labels": map[string]any{
utils.LabelKeyDeprecatedInternalID: "42", // nolint:staticcheck
},
},
"spec": map[string]any{},
}},
{Object: map[string]any{
"metadata": map[string]any{
"name": "uid",
"resourceVersion": "12",
"generation": int64(5),
"labels": map[string]any{
utils.LabelKeyDeprecatedInternalID: "42", // nolint:staticcheck
},
},
"spec": map[string]any{},
}},
},
}
firstMeta, err := meta.ListAccessor(firstPage)
require.NoError(t, err)
firstMeta.SetContinue("t1") // More pages exist
mockCli.On("List", mock.Anything, mock.Anything, mock.Anything).Return(firstPage, nil).Once()
res, err := dashboardVersionService.List(context.Background(), &query)
require.Nil(t, err)
require.Equal(t, 2, len(res.Versions))
require.Equal(t, "t1", res.ContinueToken) // Token from first fetch when limit is satisfied
mockCli.AssertNumberOfCalls(t, "List", 1) // Only one fetch needed
})
t.Run("List returns correct continue token across multiple pages", func(t *testing.T) {
dashboardService := dashboards.NewFakeDashboardService(t)
dashboardVersionService := Service{dashSvc: dashboardService, features: featuremgmt.WithFeatures()}
@@ -333,7 +385,79 @@ func TestListDashboardVersions(t *testing.T) {
res, err := dashboardVersionService.List(context.Background(), &query)
require.Nil(t, err)
require.Equal(t, 3, len(res.Versions))
require.Equal(t, "t1", res.ContinueToken) // Implementation returns continue token from first page
require.Equal(t, "", res.ContinueToken) // Should return token from last fetch (empty = no more pages)
mockCli.AssertNumberOfCalls(t, "List", 2)
})
t.Run("List returns continue token from last fetch when more pages exist", func(t *testing.T) {
dashboardService := dashboards.NewFakeDashboardService(t)
dashboardVersionService := Service{dashSvc: dashboardService, features: featuremgmt.WithFeatures()}
mockCli := new(client.MockK8sHandler)
dashboardVersionService.k8sclient = mockCli
dashboardVersionService.features = featuremgmt.WithFeatures()
dashboardService.On("GetDashboardUIDByID", mock.Anything,
mock.AnythingOfType("*dashboards.GetDashboardRefByIDQuery")).
Return(&dashboards.DashboardRef{UID: "uid"}, nil)
query := dashver.ListDashboardVersionsQuery{DashboardID: 42, Limit: 3}
mockCli.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{}, nil)
firstPage := &unstructured.UnstructuredList{
Items: []unstructured.Unstructured{
{Object: map[string]any{
"metadata": map[string]any{
"name": "uid",
"resourceVersion": "11",
"generation": int64(4),
"labels": map[string]any{
utils.LabelKeyDeprecatedInternalID: "42", // nolint:staticcheck
},
},
"spec": map[string]any{},
}},
{Object: map[string]any{
"metadata": map[string]any{
"name": "uid",
"resourceVersion": "12",
"generation": int64(5),
"labels": map[string]any{
utils.LabelKeyDeprecatedInternalID: "42", // nolint:staticcheck
},
},
"spec": map[string]any{},
}},
},
}
firstMeta, err := meta.ListAccessor(firstPage)
require.NoError(t, err)
firstMeta.SetContinue("t1")
secondPage := &unstructured.UnstructuredList{
Items: []unstructured.Unstructured{
{Object: map[string]any{
"metadata": map[string]any{
"name": "uid",
"resourceVersion": "13",
"generation": int64(6),
"labels": map[string]any{
utils.LabelKeyDeprecatedInternalID: "42", // nolint:staticcheck
},
},
"spec": map[string]any{},
}},
},
}
secondMeta, err := meta.ListAccessor(secondPage)
require.NoError(t, err)
secondMeta.SetContinue("t2") // More pages exist
mockCli.On("List", mock.Anything, mock.Anything, mock.Anything).Return(firstPage, nil).Once()
mockCli.On("List", mock.Anything, mock.Anything, mock.Anything).Return(secondPage, nil).Once()
res, err := dashboardVersionService.List(context.Background(), &query)
require.Nil(t, err)
require.Equal(t, 3, len(res.Versions))
require.Equal(t, "t2", res.ContinueToken) // Must return token from LAST fetch, not first
mockCli.AssertNumberOfCalls(t, "List", 2)
})
+9 -1
View File
@@ -280,7 +280,15 @@ func (s *Service) handleTagValues(rw http.ResponseWriter, req *http.Request) {
return
}
tempoPath := fmt.Sprintf("api/v2/search/tag/%s/values", encodedTag)
// escape tag
tag, err := url.PathUnescape(encodedTag)
if err != nil {
s.logger.Error("Failed to unescape", "error", err, "tag", encodedTag)
http.Error(rw, "Invalid 'tag' parameter", http.StatusBadRequest)
return
}
tempoPath := fmt.Sprintf("api/v2/search/tag/%s/values", tag)
s.proxyToTempo(rw, req, tempoPath)
}
@@ -51,11 +51,16 @@ function DashboardOutlineNode({ sceneObject, editPane, isEditing, depth, index }
const noTitleText = t('dashboard.outline.tree-item.no-title', '<no title>');
const children = editableElement.getOutlineChildren?.(isEditing) ?? [];
const elementInfo = editableElement.getEditableElementInfo();
const instanceName = elementInfo.instanceName === '' ? noTitleText : elementInfo.instanceName;
const outlineRename = useOutlineRename(editableElement, isEditing);
const isContainer = editableElement.getOutlineChildren ? true : false;
const visibleChildren = useMemo(() => {
const children = editableElement.getOutlineChildren?.(isEditing) ?? [];
return isEditing
? children
: children.filter((child) => !getEditableElementFor(child)?.getEditableElementInfo().isHidden);
}, [editableElement, isEditing]);
const onNodeClicked = (e: React.MouseEvent) => {
e.stopPropagation();
@@ -74,6 +79,10 @@ function DashboardOutlineNode({ sceneObject, editPane, isEditing, depth, index }
setIsCollapsed(!isCollapsed);
};
if (elementInfo.isHidden && !isEditing) {
return null;
}
return (
// todo: add proper keyboard navigation
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
@@ -130,8 +139,8 @@ function DashboardOutlineNode({ sceneObject, editPane, isEditing, depth, index }
{isContainer && !isCollapsed && (
<ul className={styles.nodeChildren} role="group">
{children.length > 0 ? (
children.map((child, i) => (
{visibleChildren.length > 0 ? (
visibleChildren.map((child, i) => (
<DashboardOutlineNode
key={child.state.key}
sceneObject={child}
@@ -190,7 +190,7 @@ describe('InspectJsonTab', () => {
expect(obj.kind).toEqual('Panel');
expect(obj.spec.id).toEqual(12);
expect(obj.spec.data.kind).toEqual('QueryGroup');
expect(tab.isEditable()).toBe(false);
expect(tab.isEditable()).toBe(true);
});
});
@@ -17,7 +17,7 @@ import {
VizPanel,
} from '@grafana/scenes';
import { LibraryPanel } from '@grafana/schema/';
import { Button, CodeEditor, Field, Select, useStyles2 } from '@grafana/ui';
import { Alert, Button, CodeEditor, Field, Select, useStyles2 } from '@grafana/ui';
import { isDashboardV2Spec } from 'app/features/dashboard/api/utils';
import { getPanelDataFrames } from 'app/features/dashboard/components/HelpWizard/utils';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
@@ -27,6 +27,7 @@ import { getPrettyJSON } from 'app/features/inspector/utils/utils';
import { reportPanelInspectInteraction } from 'app/features/search/page/reporting';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { buildVizPanel } from '../serialization/layoutSerializers/utils';
import { buildGridItemForPanel } from '../serialization/transformSaveModelToScene';
import { gridItemToPanel, vizPanelToPanel } from '../serialization/transformSceneToSaveModel';
import { vizPanelToSchemaV2 } from '../serialization/transformSceneToSaveModelSchemaV2';
@@ -37,6 +38,7 @@ import {
getQueryRunnerFor,
isLibraryPanel,
} from '../utils/utils';
import { isPanelKindV2 } from '../v2schema/validation';
export type ShowContent = 'panel-json' | 'panel-data' | 'data-frames';
@@ -45,6 +47,7 @@ export interface InspectJsonTabState extends SceneObjectState {
source: ShowContent;
jsonText: string;
onClose: () => void;
error?: string;
}
export class InspectJsonTab extends SceneObjectBase<InspectJsonTabState> {
@@ -102,38 +105,77 @@ export class InspectJsonTab extends SceneObjectBase<InspectJsonTabState> {
}
public onChangeSource = (value: SelectableValue<ShowContent>) => {
this.setState({ source: value.value!, jsonText: getJsonText(value.value!, this.state.panelRef.resolve()) });
this.setState({
source: value.value!,
jsonText: getJsonText(value.value!, this.state.panelRef.resolve()),
error: undefined,
});
};
public onApplyChange = () => {
const panel = this.state.panelRef.resolve();
const dashboard = getDashboardSceneFor(panel);
const jsonObj = JSON.parse(this.state.jsonText);
const panelModel = new PanelModel(jsonObj);
const gridItem = buildGridItemForPanel(panelModel);
const newState = sceneUtils.cloneSceneObjectState(gridItem.state);
if (!(panel.parent instanceof DashboardGridItem)) {
console.error('Cannot update state of panel', panel, gridItem);
let jsonObj: unknown;
try {
jsonObj = JSON.parse(this.state.jsonText);
} catch (e) {
this.setState({
error: t('dashboard-scene.inspect-json-tab.error-invalid-json', 'Invalid JSON'),
});
return;
}
this.state.onClose();
if (isDashboardV2Spec(dashboard.getSaveModel())) {
if (!isPanelKindV2(jsonObj)) {
this.setState({
error: t(
'dashboard-scene.inspect-json-tab.error-invalid-v2-panel',
'Panel JSON did not pass validation. Please check the JSON and try again.'
),
});
return;
}
const vizPanel = buildVizPanel(jsonObj, jsonObj.spec.id);
if (!dashboard.state.isEditing) {
dashboard.onEnterEditMode();
if (!dashboard.state.isEditing) {
dashboard.onEnterEditMode();
}
reportPanelInspectInteraction(InspectTab.JSON, 'apply', {
panel_type_changed: panel.state.pluginId !== jsonObj.spec.vizConfig.group,
panel_id_changed: getPanelIdForVizPanel(panel) !== jsonObj.spec.id,
panel_grid_pos_changed: false, // Grid cant be edited from inspect in v2 panels.
panel_targets_changed: hasQueriesChanged(getQueryRunnerFor(panel), getQueryRunnerFor(vizPanel.state.$data)),
});
panel.setState(vizPanel.state);
this.state.onClose();
} else {
const panelModel = new PanelModel(jsonObj);
const gridItem = buildGridItemForPanel(panelModel);
const newState = sceneUtils.cloneSceneObjectState(gridItem.state);
if (!(panel.parent instanceof DashboardGridItem)) {
console.error('Cannot update state of panel', panel, gridItem);
return;
}
this.state.onClose();
if (!dashboard.state.isEditing) {
dashboard.onEnterEditMode();
}
panel.parent.setState(newState);
//Report relevant updates
reportPanelInspectInteraction(InspectTab.JSON, 'apply', {
panel_type_changed: panel.state.pluginId !== panelModel.type,
panel_id_changed: getPanelIdForVizPanel(panel) !== panelModel.id,
panel_grid_pos_changed: hasGridPosChanged(panel.parent.state, newState),
panel_targets_changed: hasQueriesChanged(getQueryRunnerFor(panel), getQueryRunnerFor(newState.$data)),
});
}
panel.parent.setState(newState);
//Report relevant updates
reportPanelInspectInteraction(InspectTab.JSON, 'apply', {
panel_type_changed: panel.state.pluginId !== panelModel.type,
panel_id_changed: getPanelIdForVizPanel(panel) !== panelModel.id,
panel_grid_pos_changed: hasGridPosChanged(panel.parent.state, newState),
panel_targets_changed: hasQueriesChanged(getQueryRunnerFor(panel), getQueryRunnerFor(newState.$data)),
});
};
public onCodeEditorBlur = (value: string) => {
@@ -152,11 +194,6 @@ export class InspectJsonTab extends SceneObjectBase<InspectJsonTabState> {
return false;
}
// V2 dashboard panels are not editable from the inspect
if (isDashboardV2Spec(getDashboardSceneFor(panel).getSaveModel())) {
return false;
}
// Only support normal grid items for now and not repeated items
if (panel.parent instanceof DashboardGridItem && panel.parent.isRepeated()) {
return false;
@@ -170,14 +207,14 @@ export class InspectJsonTab extends SceneObjectBase<InspectJsonTabState> {
}
function InspectJsonTabComponent({ model }: SceneComponentProps<InspectJsonTab>) {
const { source: show, jsonText } = model.useState();
const { source: show, jsonText, error } = model.useState();
const styles = useStyles2(getPanelInspectorStyles2);
const options = model.getOptions();
return (
<div className={styles.wrap}>
<div className={styles.toolbar} data-testid={selectors.components.PanelInspector.Json.content}>
<Field label={t('dashboard.inspect-json.select-source', 'Select source')} className="flex-grow-1">
<Field label={t('dashboard.inspect-json.select-source', 'Select source')} className="flex-grow-1" noMargin>
<Select
inputId="select-source-dropdown"
options={options}
@@ -192,6 +229,12 @@ function InspectJsonTabComponent({ model }: SceneComponentProps<InspectJsonTab>)
)}
</div>
{error && (
<Alert severity="error" title={t('dashboard-scene.inspect-json-tab.validation-error', 'Validation error')}>
<p>{error}</p>
</Alert>
)}
<div className={styles.content}>
<AutoSizer disableWidth>
{({ height }) => (
@@ -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',
};
@@ -5,7 +5,19 @@ import { CoreApp, GrafanaTheme2, PanelPlugin, PanelProps } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { config, locationService } from '@grafana/runtime';
import { sceneUtils } from '@grafana/scenes';
import { Box, Button, ButtonGroup, Dropdown, Icon, Menu, Stack, Text, usePanelContext, useStyles2 } from '@grafana/ui';
import {
Box,
Button,
ButtonGroup,
Dropdown,
EmptyState,
Icon,
Menu,
Stack,
Text,
usePanelContext,
useStyles2,
} from '@grafana/ui';
import { NEW_PANEL_TITLE } from '../../dashboard/utils/dashboard';
import { DashboardInteractions } from '../utils/interactions';
@@ -92,20 +104,30 @@ function UnconfiguredPanelComp(props: PanelProps) {
);
}
const { isEditing } = dashboard.state;
return (
<Stack direction={'row'} alignItems={'center'} height={'100%'} justifyContent={'center'}>
<Box paddingBottom={2}>
<ButtonGroup>
<Button icon="sliders-v-alt" onClick={onConfigure}>
<Trans i18nKey="dashboard.new-panel.configure-button">Configure</Trans>
</Button>
<Dropdown overlay={MenuActions} placement="bottom-end" onVisibleChange={onMenuClick}>
<Button
aria-label={t('dashboard.new-panel.configure-button-menu', 'Toggle menu')}
icon={isOpen ? 'angle-up' : 'angle-down'}
/>
</Dropdown>
</ButtonGroup>
{isEditing ? (
<ButtonGroup>
<Button icon="sliders-v-alt" onClick={onConfigure}>
<Trans i18nKey="dashboard.new-panel.configure-button">Configure</Trans>
</Button>
<Dropdown overlay={MenuActions} placement="bottom-end" onVisibleChange={onMenuClick}>
<Button
aria-label={t('dashboard.new-panel.configure-button-menu', 'Toggle menu')}
icon={isOpen ? 'angle-up' : 'angle-down'}
/>
</Dropdown>
</ButtonGroup>
) : (
<EmptyState
variant="call-to-action"
message={t('dashboard.new-panel.missing-config', 'Missing panel configuration')}
hideImage
/>
)}
</Box>
</Stack>
);
@@ -91,10 +91,12 @@ export class RowItem
}
public getEditableElementInfo(): EditableDashboardElementInfo {
const isHidden = !this.state.conditionalRendering?.state.result;
return {
typeName: t('dashboard.edit-pane.elements.row', 'Row'),
instanceName: sceneGraph.interpolate(this, this.state.title, undefined, 'text'),
icon: 'list-ul',
isHidden,
};
}
@@ -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(
@@ -237,6 +238,7 @@ function getStyles(theme: GrafanaTheme2) {
}),
dragging: css({
cursor: 'move',
backgroundColor: theme.colors.background.canvas,
}),
wrapperGrow: css({
flexGrow: 1,
@@ -89,10 +89,12 @@ export class TabItem
}
public getEditableElementInfo(): EditableDashboardElementInfo {
const isHidden = !this.state.conditionalRendering?.state.result;
return {
typeName: t('dashboard.edit-pane.elements.tab', 'Tab'),
instanceName: sceneGraph.interpolate(this, this.state.title, undefined, 'text'),
icon: 'layers',
isHidden,
};
}
@@ -0,0 +1,60 @@
import {
defaultPanelKind,
defaultQueryGroupKind,
defaultPanelQueryKind,
defaultVizConfigKind,
} from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { isPanelKindV2 } from './validation';
describe('v2schema validation', () => {
it('isPanelKindV2 returns true for a minimal valid PanelKind', () => {
const panel = defaultPanelKind();
// Ensure minimal required properties exist (defaults should be fine)
panel.spec.vizConfig = defaultVizConfigKind();
panel.spec.data = defaultQueryGroupKind();
expect(isPanelKindV2(panel)).toBe(true);
});
it('returns false when kind is not "Panel"', () => {
const panel = defaultPanelKind();
// @ts-expect-error intentional invalid kind for test
panel.kind = 'NotAPanel';
expect(isPanelKindV2(panel)).toBe(false);
});
it('returns false when data kind is wrong', () => {
const panel = defaultPanelKind();
// @ts-expect-error intentional invalid kind for test
panel.spec.data = { kind: 'Wrong', spec: {} };
expect(isPanelKindV2(panel)).toBe(false);
});
it('returns false when queries contain invalid entries', () => {
const panel = defaultPanelKind();
panel.spec.data = defaultQueryGroupKind();
// @ts-expect-error push an invalid query shape
panel.spec.data.spec.queries = [{}];
expect(isPanelKindV2(panel)).toBe(false);
// Ensure a valid query shape passes
panel.spec.data.spec.queries = [defaultPanelQueryKind()];
expect(isPanelKindV2(panel)).toBe(true);
});
it('returns false when vizConfig.group is not a string', () => {
const panel = defaultPanelKind();
panel.spec.vizConfig = defaultVizConfigKind();
// @ts-expect-error force wrong type
panel.spec.vizConfig.group = 42;
expect(isPanelKindV2(panel)).toBe(false);
});
it('returns false when transparent is not a boolean', () => {
const panel = defaultPanelKind();
// @ts-expect-error wrong type
panel.spec.transparent = 'yes';
expect(isPanelKindV2(panel)).toBe(false);
});
});
@@ -0,0 +1,137 @@
import {
PanelKind,
QueryGroupKind,
VizConfigKind,
PanelQueryKind,
TransformationKind,
} from '@grafana/schema/dist/esm/schema/dashboard/v2';
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isPanelQueryKind(value: unknown): value is PanelQueryKind {
if (!isObject(value)) {
return false;
}
if (value.kind !== 'PanelQuery' || !isObject(value.spec)) {
return false;
}
// Minimal checks for query spec; accept additional properties
if (typeof value.spec.refId !== 'string') {
return false;
}
if (typeof value.spec.hidden !== 'boolean') {
return false;
}
// value.spec.query is an opaque "DataQueryKind" which is { kind: string, spec: Record<string, any> }
const q = value.spec.query;
if (!isObject(q) || typeof q.kind !== 'string' || !isObject(q.spec)) {
return false;
}
return true;
}
function isTransformationKind(value: unknown): value is TransformationKind {
if (!isObject(value)) {
return false;
}
if (typeof value.kind !== 'string') {
return false;
}
if (!isObject(value.spec)) {
return false;
}
return true;
}
function isQueryGroupKind(value: unknown): value is QueryGroupKind {
if (!isObject(value)) {
return false;
}
if (value.kind !== 'QueryGroup' || !isObject(value.spec)) {
return false;
}
const spec = value.spec;
if (!Array.isArray(spec.queries) || !spec.queries.every(isPanelQueryKind)) {
return false;
}
if (!Array.isArray(spec.transformations) || !spec.transformations.every(isTransformationKind)) {
return false;
}
if (!isObject(spec.queryOptions)) {
return false;
}
return true;
}
function isVizConfigKind(value: unknown): value is VizConfigKind {
if (!isObject(value)) {
return false;
}
if (value.kind !== 'VizConfig') {
return false;
}
if (typeof value.group !== 'string') {
return false;
}
if (typeof value.version !== 'string') {
return false;
}
if (!isObject(value.spec)) {
return false;
}
const spec = value.spec;
if (!isObject(spec.options)) {
return false;
}
if (!isObject(spec.fieldConfig)) {
return false;
}
// Minimal fieldConfig shape (defaults/overrides may be empty)
if (!isObject(spec.fieldConfig)) {
return false;
}
return true;
}
export function isPanelKindV2(value: unknown): value is PanelKind {
if (!isObject(value)) {
return false;
}
if (value.kind !== 'Panel') {
return false;
}
if (!isObject(value.spec)) {
return false;
}
const spec = value.spec;
if (typeof spec.id !== 'number') {
return false;
}
if (typeof spec.title !== 'string') {
return false;
}
if (typeof spec.description !== 'string') {
return false;
}
if (!Array.isArray(spec.links)) {
return false;
}
if (!isQueryGroupKind(spec.data)) {
return false;
}
if (!isVizConfigKind(spec.vizConfig)) {
return false;
}
if (spec.transparent !== undefined && typeof spec.transparent !== 'boolean') {
return false;
}
return true;
}
export function validatePanelKindV2(value: unknown): asserts value is PanelKind {
if (!isPanelKindV2(value)) {
throw new Error('Provided JSON is not a valid v2 Panel spec');
}
}
@@ -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',
@@ -190,9 +190,7 @@ export default class TempoLanguageProvider extends LanguageProvider {
* @returns the encoded tag
*/
private encodeTag = (tag: string): string => {
// If we call `encodeURIComponent` only once, we still get an error when issuing a request to the backend
// Reference: https://stackoverflow.com/a/37456192
return encodeURIComponent(encodeURIComponent(tag));
return encodeURIComponent(tag);
};
generateQueryFromFilters({
@@ -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",
+5 -1
View File
@@ -5133,6 +5133,7 @@
"empty-state-message": "Run a query to visualize it here or go to all visualizations to add other panel types",
"menu-open-panel-editor": "Configure",
"menu-use-library-panel": "Use library panel",
"missing-config": "Missing panel configuration",
"suggestions": {
"empty-state-message": "Run a query to start seeing suggested visualizations"
}
@@ -6145,7 +6146,10 @@
"no-data-found": "No data found"
},
"inspect-json-tab": {
"apply": "Apply"
"apply": "Apply",
"error-invalid-json": "Invalid JSON",
"error-invalid-v2-panel": "Panel JSON did not pass validation. Please check the JSON and try again.",
"validation-error": "Validation error"
},
"interval-variable-form": {
"description-auto-option": "Dynamically calculates interval by dividing time range by the count specified",
+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": "新儀表板",