update metric simulation styles

This commit is contained in:
swithek 2026-09-19 13:00:36 +02:00
parent 361605c93d
commit 7467e1eca2
17 changed files with 280 additions and 233 deletions

View File

@ -348,6 +348,8 @@ test context).
functions.
- Tailwind utilities by default; custom rules go in `main.css` and are
applied by class. No `<style>` blocks or static inline `style=`.
- Lengths are rem or screen proportions (`dvh`, `dvw`, `%`), never `px`,
including in arbitrary values: `max-h-[min(90dvh,60rem)]`.
## PromQL grammar

View File

@ -13,6 +13,7 @@ import {
makeEditor,
makeNode,
mountNodeView,
mountNodeViewUnderTooltipProvider,
} from "../../test-helpers/node-view"
import { DiffStatus } from "~/components/editor/diff/position-map"
import {
@ -20,7 +21,7 @@ import {
disposeMockEndpoints,
makeXid,
} from "~/composables/api/test-helpers"
import { emitFrom } from "~/components/test-helpers"
import { emitFrom, renderedIconNames } from "~/components/test-helpers"
const DOCUMENT_ID = makeXid("doc")
const BRANCH_ID = makeXid("branch")
@ -77,11 +78,15 @@ function mountBlock(
getPos?: () => number | undefined
nodeAtPos?: { attrs: Record<string, unknown> } | null
editing?: { uid: string; name: string; color: string } | null
underTooltipProvider?: boolean
} = {},
) {
const uid = options.uid ?? nextUid()
const mount = options.underTooltipProvider
? mountNodeViewUnderTooltipProvider
: mountNodeView
return mountNodeView(MainBlock, {
return mount(MainBlock, {
node: makeNode({ uid: uid, ...options.attrs }),
editor: makeMetricEditor({
nodeAtPos:
@ -205,6 +210,18 @@ describe("<MetricMainBlock>", { concurrent: false }, () => {
expect(storedConfig(uid)?.axisBounds).toEqual({ min: 0, max: 10 })
})
it("reads a block saved without a chart type as a time series", async ({
expect,
}) => {
const uid = nextUid()
await mountBlock({ uid: uid, attrs: { visualizationType: null } })
expect(storedConfig(uid)?.visualizationType).toBe(
GenericQueryChartType.Line,
)
})
it("publishes nothing for a block shown inside a diff", async ({
expect,
}) => {
@ -298,6 +315,7 @@ describe("<MetricMainBlock>", { concurrent: false }, () => {
await mountBlock({
uid: uid,
attrs: { simulationPreset: MetricSimulationPreset.DiskUsage },
underTooltipProvider: true,
})
expect(storedConfig(uid)?.simulationPreset).toBe(
@ -305,6 +323,28 @@ describe("<MetricMainBlock>", { concurrent: false }, () => {
)
})
it("shows the simulation icon while the visualization simulates", async ({
expect,
}) => {
const wrapper = await mountBlock({ underTooltipProvider: true })
emitFrom(wrapper, VisualizationContainer, "simulation-visible", true)
await nextTick()
expect(renderedIconNames(wrapper)).toContain("mingcute:test-tube-line")
})
it("hides the simulation icon while the visualization draws real data", async ({
expect,
}) => {
const wrapper = await mountBlock()
emitFrom(wrapper, VisualizationContainer, "simulation-visible", false)
await nextTick()
expect(renderedIconNames(wrapper)).not.toContain("mingcute:test-tube-line")
})
it("flattens a legacy config on the first edit", async ({ expect }) => {
const updateAttributes = vi.fn()
const uid = nextUid()

View File

@ -73,7 +73,8 @@ function migrateLegacyIfNeeded(fieldAttrs: Record<string, any>) {
safeUpdateAttributes({
title: legacy.title ?? "",
dataSourceId: legacy.dataSourceId ?? null,
visualizationType: legacy.type ?? legacy.visualizationType ?? null,
visualizationType:
legacy.type ?? legacy.visualizationType ?? GenericQueryChartType.Line,
queries: legacy.queries ?? null,
timeRange: legacy.timeRange ?? null,
refreshInterval: legacy.refreshInterval ?? null,
@ -123,9 +124,9 @@ const config = reactive({
get: () =>
legacyConfig.value?.type ??
legacyConfig.value?.visualizationType ??
(props.node.attrs
.visualizationType as MetricConfig["visualizationType"]) ??
null,
(props.node.attrs.visualizationType as
MetricConfig["visualizationType"] | null) ??
GenericQueryChartType.Line,
set: (v) => {
migrateLegacyIfNeeded({ visualizationType: v })
},
@ -243,6 +244,7 @@ const lastOtherEditingUser = computed(() => {
})
const disableVisualizationRefresh = ref(false)
const simulationVisible = ref(false)
watchImmediate(
[
@ -346,7 +348,20 @@ function disableVisualizationRefreshTemporarily() {
>
{{ lastOtherEditingUser.name }}
</div>
<div class="absolute top-1 right-1.5 z-1 flex items-center gap-1">
<div
class="absolute top-1 right-1.5 z-1 flex items-center rounded-md bg-background pl-0.5"
>
<ShadcnUiTooltip v-if="simulationVisible">
<ShadcnUiTooltipTrigger as-child>
<Icon
name="mingcute:test-tube-line"
class="size-3.5 text-foreground"
/>
</ShadcnUiTooltipTrigger>
<ShadcnUiTooltipContent side="bottom">
{{ $t("editor.metrics.simulation.tooltip") }}
</ShadcnUiTooltipContent>
</ShadcnUiTooltip>
<ShadcnUiPopoverTrigger as-child>
<ShadcnUiButton
variant="ghost-plain"
@ -362,6 +377,7 @@ function disableVisualizationRefreshTemporarily() {
:uid="props.node.attrs.uid"
:disable-refresh="disableVisualizationRefresh"
@simulate="(preset) => (config.simulationPreset = preset)"
@simulation-visible="(v) => (simulationVisible = v)"
/>
</div>
<ShadcnUiPopoverContent class="w-50" side="right" side-flip align="start">

View File

@ -95,7 +95,9 @@ function openModal() {
size="2sm"
@click.stop="openModal"
>
<Icon name="lucide:square-pen" />
<Icon
:name="isEditingDisabled ? 'mingcute:eye-line' : 'mingcute:pencil-line'"
/>
{{
!isEditingDisabled
? $t("editor.metrics.config.modal-trigger-button-normal")

View File

@ -109,54 +109,7 @@ describe("<VisualizationContainer>", { concurrent: false }, () => {
afterEach(disposeMockEndpoints)
it("asks for a visualization type before anything else", async ({
expect,
}) => {
const wrapper = await mountContainer(
metricConfig({ visualizationType: null }),
)
expect(wrapper.text()).toContain(
t("editor.metrics.status.type-not-selected.title"),
)
expect(wrapper.text()).toContain(
t("editor.metrics.status.type-not-selected.normal-action-button"),
)
})
it("offers only to view the metrics in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountContainer(
metricConfig({ visualizationType: null }),
)
expect(wrapper.text()).toContain(
t("editor.metrics.status.type-not-selected.readonly-action-button"),
)
})
it("hides the action button when the host asks it to", async ({ expect }) => {
const wrapper = await mountContainer(
metricConfig({ visualizationType: null }),
{ hideEmptyActionButton: true },
)
expect(wrapper.findAll("button")).toHaveLength(0)
})
it("shows a bare placeholder in a simplified host", async ({ expect }) => {
const wrapper = await mountContainer(
metricConfig({ visualizationType: null }),
{ simplifiedEmpty: true },
)
expect(wrapper.text()).toBe(
t("editor.metrics.status.simplified-config-in-progress.title"),
)
})
it("asks for a data source once a type is chosen", async ({ expect }) => {
it("asks for a data source before anything else", async ({ expect }) => {
const wrapper = await mountContainer(
metricConfig({ dataSourceId: null, queries: [] }),
)
@ -164,6 +117,32 @@ describe("<VisualizationContainer>", { concurrent: false }, () => {
expect(wrapper.text()).toContain(
t("editor.metrics.status.data-source-not-selected.title"),
)
expect(wrapper.text()).toContain(
t("editor.metrics.status.data-source-not-selected.normal-action-button"),
)
})
it("offers only to view the metrics in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountContainer(
metricConfig({ dataSourceId: null, queries: [] }),
)
expect(wrapper.text()).toContain(
t(
"editor.metrics.status.data-source-not-selected.readonly-action-button",
),
)
})
it("hides the action button when the host asks it to", async ({ expect }) => {
const wrapper = await mountContainer(
metricConfig({ dataSourceId: null, queries: [] }),
{ hideEmptyActionButton: true },
)
expect(wrapper.findAll("button")).toHaveLength(0)
})
it("shows a bare placeholder for a missing data source in a simplified host", async ({
@ -319,13 +298,13 @@ describe("<VisualizationContainer>", { concurrent: false }, () => {
it("opens the config modal from the action button", async ({ expect }) => {
const uid = nextUid()
const wrapper = await mountContainer(
metricConfig({ visualizationType: null }),
metricConfig({ dataSourceId: null, queries: [] }),
{ uid: uid },
)
await findButtonByText(
wrapper,
t("editor.metrics.status.type-not-selected.normal-action-button"),
t("editor.metrics.status.data-source-not-selected.normal-action-button"),
).trigger("click")
expect(useEditorStore().activeMetricBlockConfig).toBe(uid)
@ -512,7 +491,7 @@ describe("<VisualizationContainer>", { concurrent: false }, () => {
await vi.waitFor(() => {
expect(calls.length).toBeGreaterThan(0)
}, WAIT_FOR_OPTIONS)
expect(wrapper.text()).not.toContain(t("editor.metrics.simulation.label"))
expect(simulationVisible(wrapper)).toBe(false)
})
// a simulation stands in for a metric that has not arrived, not
@ -535,9 +514,7 @@ describe("<VisualizationContainer>", { concurrent: false }, () => {
)
await vi.waitFor(() => {
expect(wrapper.text()).not.toContain(
t("editor.metrics.simulation.label"),
)
expect(simulationVisible(wrapper)).toBe(false)
}, WAIT_FOR_OPTIONS)
},
)
@ -552,7 +529,7 @@ describe("<VisualizationContainer>", { concurrent: false }, () => {
metricConfig({ simulationPreset: MetricSimulationPreset.CPUUsage }),
)
expect(wrapper.text()).toContain(t("editor.metrics.simulation.label"))
expect(simulationVisible(wrapper)).toBe(true)
})
it("draws the window the block is configured for", async ({ expect }) => {
@ -600,7 +577,7 @@ describe("<VisualizationContainer>", { concurrent: false }, () => {
},
)
it("marks a simulated block for every reader", async ({ expect }) => {
it("reports a simulated block for every reader", async ({ expect }) => {
useEditorMeta().setEditable(false)
mockEndpoint("GET", QUERY_URL, () => seriesResult())
@ -608,15 +585,15 @@ describe("<VisualizationContainer>", { concurrent: false }, () => {
metricConfig({ simulationPreset: MetricSimulationPreset.CPUUsage }),
)
expect(wrapper.text()).toContain(t("editor.metrics.simulation.label"))
expect(simulationVisible(wrapper)).toBe(true)
})
it("leaves an unsimulated block unmarked", async ({ expect }) => {
it("reports an unsimulated block as not simulating", async ({ expect }) => {
mockEndpoint("GET", QUERY_URL, () => seriesResult())
const wrapper = await mountContainer(metricConfig())
expect(wrapper.text()).not.toContain(t("editor.metrics.simulation.label"))
expect(simulationVisible(wrapper)).toBe(false)
})
it("asks core whether the real data has arrived", async ({ expect }) => {
@ -727,7 +704,7 @@ describe("<VisualizationContainer>", { concurrent: false }, () => {
expect(wrapper.text()).not.toContain(
t("editor.metrics.status.no-data-loaded.title"),
)
expect(wrapper.text()).toContain(t("editor.metrics.simulation.label"))
expect(simulationVisible(wrapper)).toBe(true)
expect(
wrapper.findComponent({ name: "LineChart" }).props("seriesData"),
).toHaveLength(3)
@ -739,7 +716,7 @@ describe("<VisualizationContainer>", { concurrent: false }, () => {
wrapper.findComponent({ name: "LineChart" }).props("seriesData"),
).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
expect(wrapper.text()).not.toContain(t("editor.metrics.simulation.label"))
expect(simulationVisible(wrapper)).toBe(false)
})
// a block whose queries cannot run is never answered, and holding
@ -764,7 +741,7 @@ describe("<VisualizationContainer>", { concurrent: false }, () => {
})
await nextTick()
expect(wrapper.text()).not.toContain(t("editor.metrics.simulation.label"))
expect(simulationVisible(wrapper)).toBe(false)
expect(calls).toHaveLength(0)
})
@ -826,7 +803,7 @@ describe("<VisualizationContainer>", { concurrent: false }, () => {
const wrapper = await mountContainer(config, { uid: uid })
expect(wrapper.text()).toContain(t("editor.metrics.simulation.label"))
expect(simulationVisible(wrapper)).toBe(true)
// core removes the attribute on the live document, which reaches
// the editor as a node attribute change
@ -837,7 +814,7 @@ describe("<VisualizationContainer>", { concurrent: false }, () => {
await vi.waitFor(() => {
expect(calls.length).toBeGreaterThan(0)
}, WAIT_FOR_OPTIONS)
expect(wrapper.text()).not.toContain(t("editor.metrics.simulation.label"))
expect(simulationVisible(wrapper)).toBe(false)
})
it("keeps drawing when the check fails", async ({ expect }) => {
@ -859,3 +836,8 @@ describe("<VisualizationContainer>", { concurrent: false }, () => {
})
})
})
// whether the container last reported simulated data as visible
function simulationVisible(wrapper: VueWrapper): boolean | undefined {
return wrapper.emitted<[boolean]>("simulation-visible")?.at(-1)?.[0]
}

View File

@ -33,7 +33,7 @@ const props = defineProps<{
disableRefresh?: boolean
}>()
const emit = defineEmits<{
(event: "loading", v: boolean): void
(event: "loading" | "simulation-visible", v: boolean): void
(event: "simulate", preset: MetricSimulationPreset): void
}>()
@ -119,7 +119,7 @@ const fetchMetricData = useMultipleGenericQueries(
? debouncedConfig.value.visualizationType
: props.config.visualizationType
if (!queries?.length || !timeRange || !visualizationType) {
if (!queries?.length || !timeRange) {
return null
}
@ -163,6 +163,14 @@ const canSimulate = computed(() => {
return queryRunnable.value && !dataSourceUnavailable.value
})
const simulationOffered = computed(() => {
return !isEditingDisabled.value && canSimulate.value
})
const metricsButtonIcon = computed(() => {
return isEditingDisabled.value ? "mingcute:eye-line" : "mingcute:pencil-line"
})
// what the block draws, which outlives the attribute by one load
const drawnSimulationPreset = computed(() => {
return (
@ -310,6 +318,13 @@ watch(
},
)
watchImmediate(
() => !!drawnSimulationPreset.value,
(visible) => {
emit("simulation-visible", visible)
},
)
// labels series the query gave no legend format for, counting up per
// merge call ("Line A", "Line B", ...)
function orderedLegendLabel() {
@ -416,13 +431,6 @@ function openModal() {
</script>
<template>
<div class="relative flex size-full min-w-0 items-center justify-center">
<div
v-if="drawnSimulationPreset"
class="pointer-events-none absolute top-2.5 left-1/2 z-1 flex -translate-x-1/2 items-center gap-1 text-2sm text-foreground/70"
>
<Icon name="lucide:hourglass" class="size-3" />
{{ $t("editor.metrics.simulation.label") }}
</div>
<template v-if="data.status === GenericQueryResultStatus.Ok && data.data">
<LineChart
v-if="props.config.visualizationType === GenericQueryChartType.Line"
@ -480,8 +488,12 @@ function openModal() {
{{ $t("editor.metrics.status.no-data-loaded.description") }}
</ShadcnUiEmptyDescription>
</ShadcnUiEmptyHeader>
<ShadcnUiEmptyContent v-if="!props.hideEmptyActionButton">
<ShadcnUiButton variant="outline" size="2sm" @click="openModal">
<ShadcnUiEmptyContent
v-if="!props.hideEmptyActionButton"
:class="simulationOffered && 'grid w-auto grid-cols-2'"
>
<ShadcnUiButton size="2sm" @click="openModal">
<Icon :name="metricsButtonIcon" class="size-3.5" />
{{
!isEditingDisabled
? $t(
@ -493,11 +505,13 @@ function openModal() {
}}
</ShadcnUiButton>
<ShadcnUiButton
v-if="!isEditingDisabled && canSimulate"
v-if="simulationOffered"
variant="outline"
size="2sm"
class="gap-0.5"
@click="startSimulation"
>
<Icon name="mingcute:test-tube-line" class="size-3.5" />
{{
$t(
"editor.metrics.status.no-data-loaded.simulate-action-button",
@ -521,7 +535,8 @@ function openModal() {
</ShadcnUiEmptyDescription>
</ShadcnUiEmptyHeader>
<ShadcnUiEmptyContent v-if="!props.hideEmptyActionButton">
<ShadcnUiButton variant="outline" size="2sm" @click="openModal">
<ShadcnUiButton size="2sm" @click="openModal">
<Icon :name="metricsButtonIcon" class="size-3.5" />
{{
!isEditingDisabled
? $t(
@ -548,8 +563,14 @@ function openModal() {
</ShadcnUiEmptyHeader>
<!-- editing the block is what the simplified state is already
inside of, but simulating it is not offered anywhere else -->
<ShadcnUiEmptyContent v-if="!isEditingDisabled && canSimulate">
<ShadcnUiButton variant="outline" size="2sm" @click="startSimulation">
<ShadcnUiEmptyContent v-if="simulationOffered">
<ShadcnUiButton
variant="outline"
size="2sm"
class="gap-0.5"
@click="startSimulation"
>
<Icon name="mingcute:test-tube-line" class="size-3.5" />
{{
$t("editor.metrics.status.no-data-loaded.simulate-action-button")
}}
@ -557,49 +578,6 @@ function openModal() {
</ShadcnUiEmptyContent>
</ShadcnUiEmpty>
</div>
<div
v-else-if="data.status === GenericQueryResultStatus.TypeNotSelected"
class="text-foreground"
>
<ShadcnUiEmpty v-if="!props.simplifiedEmpty">
<ShadcnUiEmptyHeader>
<ShadcnUiEmptyMedia variant="icon" class="size-9">
<Icon name="lucide:chart-line" class="size-6" />
</ShadcnUiEmptyMedia>
<ShadcnUiEmptyTitle>
{{ $t("editor.metrics.status.type-not-selected.title") }}
</ShadcnUiEmptyTitle>
<ShadcnUiEmptyDescription>
{{ $t("editor.metrics.status.type-not-selected.description") }}
</ShadcnUiEmptyDescription>
</ShadcnUiEmptyHeader>
<ShadcnUiEmptyContent v-if="!props.hideEmptyActionButton">
<ShadcnUiButton variant="outline" size="2sm" @click="openModal">
{{
!isEditingDisabled
? $t(
"editor.metrics.status.type-not-selected.normal-action-button",
)
: $t(
"editor.metrics.status.type-not-selected.readonly-action-button",
)
}}
</ShadcnUiButton>
</ShadcnUiEmptyContent>
</ShadcnUiEmpty>
<ShadcnUiEmpty v-else>
<ShadcnUiEmptyHeader>
<ShadcnUiEmptyMedia variant="icon" class="size-9">
<Icon name="lucide:chart-line" class="size-6" />
</ShadcnUiEmptyMedia>
<ShadcnUiEmptyTitle>
{{
$t("editor.metrics.status.simplified-config-in-progress.title")
}}
</ShadcnUiEmptyTitle>
</ShadcnUiEmptyHeader>
</ShadcnUiEmpty>
</div>
<div
v-else-if="
data.status === GenericQueryResultStatus.QueryError &&
@ -638,7 +616,8 @@ function openModal() {
</ShadcnUiEmptyDescription>
</ShadcnUiEmptyHeader>
<ShadcnUiEmptyContent v-if="!props.hideEmptyActionButton">
<ShadcnUiButton variant="outline" size="2sm" @click="openModal">
<ShadcnUiButton size="2sm" @click="openModal">
<Icon :name="metricsButtonIcon" class="size-3.5" />
{{
!isEditingDisabled
? $t("editor.metrics.status.invalid-data.normal-action-button")
@ -664,7 +643,8 @@ function openModal() {
</ShadcnUiEmptyDescription>
</ShadcnUiEmptyHeader>
<ShadcnUiEmptyContent v-if="!props.hideEmptyActionButton">
<ShadcnUiButton variant="outline" size="2sm" @click="openModal">
<ShadcnUiButton size="2sm" @click="openModal">
<Icon :name="metricsButtonIcon" class="size-3.5" />
{{
!isEditingDisabled
? $t("editor.metrics.status.invalid-data.normal-action-button")

View File

@ -1,4 +1,3 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { enableAutoUnmount } from "@vue/test-utils"
import { afterEach, beforeEach, describe, it } from "vitest"
import BaseModal from "./BaseModal.client.vue"
@ -20,6 +19,7 @@ import {
} from "~/composables/api/test-helpers"
import {
emitFrom,
mountUnderTooltipProvider,
stubViewportMatches,
t,
teleportedButton,
@ -67,7 +67,7 @@ function activateBlock(
}
function mountModal() {
return mountSuspended(BaseModal)
return mountUnderTooltipProvider(BaseModal, {})
}
function dialogText(): string {
@ -275,6 +275,30 @@ describe("<MetricConfigBaseModal>", { concurrent: false }, () => {
).toBe(MetricSimulationPreset.HTTPLatency)
})
it("shows the simulation icon while the preview simulates", async ({
expect,
}) => {
activateBlock()
const wrapper = await mountModal()
emitFrom(wrapper, VisualizationContainer, "simulation-visible", true)
await nextTick()
expect(simulationIconShown()).toBe(true)
})
it("hides the simulation icon while the preview draws real data", async ({
expect,
}) => {
activateBlock()
const wrapper = await mountModal()
emitFrom(wrapper, VisualizationContainer, "simulation-visible", false)
await nextTick()
expect(simulationIconShown()).toBe(false)
})
it("stands aside when the reader asks for the data source settings", async ({
expect,
}) => {
@ -285,6 +309,15 @@ describe("<MetricConfigBaseModal>", { concurrent: false }, () => {
await nextTick()
expect(useEditorStore().activeMetricBlockConfig).toBeNull()
expect(wrapper.emitted("open-settings")).toHaveLength(1)
expect(
wrapper.findComponent(BaseModal).emitted("open-settings"),
).toHaveLength(1)
})
})
// the modal teleports to the body, out of the wrapper's reach
function simulationIconShown(): boolean {
return (
document.body.querySelector('[class~="i-mingcute:test-tube-line"]') !== null
)
}

View File

@ -82,6 +82,7 @@ const isRefreshIntervalModified = computed(() => {
})
const chartTopOptionElem = useTemplateRef<HTMLDivElement>("chart-top-options")
const { width: chartTopOptionWidth } = useElementSize(chartTopOptionElem)
const simulationVisible = ref(false)
function updateOpenStatus(v: boolean) {
if (v) {
@ -147,10 +148,10 @@ function openSettings() {
</ShadcnUiDialogHeader>
<div
v-if="config"
class="flex flex-col rounded-md border bg-muted/50 lg:flex-row"
class="flex flex-col rounded-md border bg-muted/50 lg:h-126 lg:flex-row"
>
<div class="flex min-w-0 flex-1 flex-col self-stretch">
<div class="relative h-70 min-w-0 border-b">
<div class="relative h-70 min-w-0 shrink-0 border-b">
<div
ref="chart-top-options"
:class="
@ -159,6 +160,17 @@ function openSettings() {
)
"
>
<ShadcnUiTooltip v-if="simulationVisible">
<ShadcnUiTooltipTrigger as-child>
<Icon
name="mingcute:test-tube-line"
class="size-3.5 text-foreground"
/>
</ShadcnUiTooltipTrigger>
<ShadcnUiTooltipContent side="bottom">
{{ $t("editor.metrics.simulation.tooltip") }}
</ShadcnUiTooltipContent>
</ShadcnUiTooltip>
<div class="flex flex-col gap-0.5 md:gap-1">
<ShadcnUiSelect v-model="config.timeRange">
<ShadcnUiSelectTrigger
@ -297,6 +309,7 @@ function openSettings() {
simplified-empty
:uid="editorStore.activeMetricBlockConfig!"
@simulate="(preset) => (config!.simulationPreset = preset)"
@simulation-visible="(v) => (simulationVisible = v)"
/>
</div>
<div v-if="!isMinWidth1024px" class="self-stretch border-b">
@ -307,7 +320,7 @@ function openSettings() {
@open-settings="openSettings"
/>
</div>
<div>
<div class="lg:min-h-0 lg:flex-1 lg:overflow-y-auto">
<BottomConfig
:model-value="config"
:diff-status="diffStatus"
@ -315,7 +328,10 @@ function openSettings() {
/>
</div>
</div>
<div v-if="isMinWidth1024px" class="w-70 self-stretch border-l">
<div
v-if="isMinWidth1024px"
class="w-70 self-stretch border-l lg:overflow-y-auto"
>
<ConfigSidebar
:model-value="config"
:diff-status="diffStatus"

View File

@ -287,6 +287,34 @@ function removeThreshold(index: number) {
</div>
<div class="mt-1 h-px bg-border" />
</div>
<div v-if="config.simulationPreset">
<div class="flex flex-col gap-1 px-1.75">
<span class="text-2sm font-medium">
{{ $t("editor.metrics.simulation.section-title") }}
</span>
<span class="mb-1 text-2xs text-muted-foreground">
{{ $t("editor.metrics.simulation.section-description") }}
</span>
<ConfigField>
<template #label>
<span>
{{ $t("editor.metrics.simulation.preset-label") }}
</span>
</template>
<SimulationPresetSelect v-model="config.simulationPreset" />
</ConfigField>
<ShadcnUiButton
v-if="!isEditingDisabled"
variant="outline-transparent"
size="custom"
class="h-[1.775rem]! justify-center px-1.5 text-2sm font-normal"
@click="config.simulationPreset = null"
>
{{ $t("editor.metrics.simulation.stop-button") }}
</ShadcnUiButton>
</div>
<div class="mt-2 h-px bg-border" />
</div>
<div class="px-1.75">
<ConfigField>
<template #label>
@ -595,33 +623,5 @@ function removeThreshold(index: number) {
/>
</ConfigField>
</div>
<div v-if="config.simulationPreset">
<div class="mb-2 h-px bg-border" />
<div class="flex flex-col gap-1 px-1.75">
<span class="text-2sm font-medium">
{{ $t("editor.metrics.simulation.section-title") }}
</span>
<span class="text-2xs text-muted-foreground">
{{ $t("editor.metrics.simulation.section-description") }}
</span>
<ConfigField>
<template #label>
<span>
{{ $t("editor.metrics.simulation.preset-label") }}
</span>
</template>
<SimulationPresetSelect v-model="config.simulationPreset" />
</ConfigField>
<ShadcnUiButton
v-if="!isEditingDisabled"
variant="outline-transparent"
size="custom"
class="h-[1.775rem]! justify-center px-1.5 text-2sm font-normal"
@click="config.simulationPreset = null"
>
{{ $t("editor.metrics.simulation.stop-button") }}
</ShadcnUiButton>
</div>
</div>
</div>
</template>

View File

@ -34,7 +34,9 @@ describe("<VisualizationTypeSelect>", { concurrent: false }, () => {
})
it("offers the three visualization types", async ({ expect }) => {
const wrapper = await mountTypeSelect({ modelValue: configWith(null) })
const wrapper = await mountTypeSelect({
modelValue: configWith(GenericQueryChartType.Line),
})
expect(tiles(wrapper).map((t) => t.text())).toEqual([
t("editor.metrics.config.type-options.line-chart.title"),
@ -52,22 +54,26 @@ describe("<VisualizationTypeSelect>", { concurrent: false }, () => {
expect(at(tiles(wrapper), LINE).classes()).not.toContain("border-primary")
})
it("marks nothing while no type is chosen", async ({ expect }) => {
const wrapper = await mountTypeSelect({ modelValue: configWith(null) })
expect(
tiles(wrapper).filter((t) => t.classes().includes("border-primary")),
).toHaveLength(0)
})
it.for([
{ index: LINE, expected: GenericQueryChartType.Line },
{ index: BAR, expected: GenericQueryChartType.Bar },
{ index: GAUGE, expected: GenericQueryChartType.Gauge },
{
index: LINE,
input: GenericQueryChartType.Gauge,
expected: GenericQueryChartType.Line,
},
{
index: BAR,
input: GenericQueryChartType.Line,
expected: GenericQueryChartType.Bar,
},
{
index: GAUGE,
input: GenericQueryChartType.Line,
expected: GenericQueryChartType.Gauge,
},
])(
"stores the $expected type when picked",
async ({ index, expected }, { expect }) => {
const config = configWith(null)
async ({ index, input, expected }, { expect }) => {
const config = configWith(input)
const wrapper = await mountTypeSelect({ modelValue: config })
await at(tiles(wrapper), index).trigger("click")
@ -78,22 +84,22 @@ describe("<VisualizationTypeSelect>", { concurrent: false }, () => {
it("changes nothing in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const config = configWith(null)
const config = configWith(GenericQueryChartType.Line)
const wrapper = await mountTypeSelect({ modelValue: config })
await at(tiles(wrapper), BAR).trigger("click")
expect(config.visualizationType).toBeNull()
expect(config.visualizationType).toBe(GenericQueryChartType.Line)
})
it("changes nothing while a reviewable diff is shown", async ({ expect }) => {
useEditorStore().setReviewableDiffActive(true)
const config = configWith(null)
const config = configWith(GenericQueryChartType.Line)
const wrapper = await mountTypeSelect({ modelValue: config })
await at(tiles(wrapper), BAR).trigger("click")
expect(config.visualizationType).toBeNull()
expect(config.visualizationType).toBe(GenericQueryChartType.Line)
})
it("changes nothing when there is no config to edit", async ({ expect }) => {

View File

@ -226,7 +226,7 @@ describe("MetricBlock", () => {
config: null,
title: "",
dataSourceId: null,
visualizationType: null,
visualizationType: GenericQueryChartType.Line,
queries: [{ name: "Query 1", query: "", legendFormat: "" }],
timeRange: TimeRangePreset.Last5Minutes,
refreshInterval: RefreshInterval.M5,

View File

@ -29,7 +29,7 @@ describe("defaultMetricConfig", () => {
expect(defaultMetricConfig()).toEqual({
title: "",
dataSourceId: null,
visualizationType: null,
visualizationType: GenericQueryChartType.Line,
queries: [{ name: "Query 1", query: "", legendFormat: "" }],
timeRange: TimeRangePreset.Last5Minutes,
refreshInterval: RefreshInterval.M5,
@ -103,7 +103,7 @@ describe("buildConfigFromNodeAttrs", () => {
expect(buildConfigFromNodeAttrs({ config: {} })).toEqual({
title: "",
dataSourceId: null,
visualizationType: null,
visualizationType: GenericQueryChartType.Line,
queries: null,
timeRange: null,
refreshInterval: null,
@ -161,7 +161,7 @@ describe("buildConfigFromNodeAttrs", () => {
expect(buildConfigFromNodeAttrs({})).toEqual({
title: "",
dataSourceId: null,
visualizationType: null,
visualizationType: GenericQueryChartType.Line,
queries: null,
timeRange: null,
refreshInterval: null,

View File

@ -79,7 +79,7 @@ export enum MetricSimulationPreset {
export interface MetricConfig {
title: string
dataSourceId: string | null
visualizationType: GenericQueryChartType | null
visualizationType: GenericQueryChartType
queries:
| {
name: string
@ -118,7 +118,7 @@ export function defaultMetricConfig(): MetricConfig {
return {
title: "",
dataSourceId: null,
visualizationType: null,
visualizationType: GenericQueryChartType.Line,
// NOTE: this doesn't use i18n because this isn't a reactive context
queries: [{ name: "Query 1", query: "", legendFormat: "" }],
timeRange: TimeRangePreset.Last5Minutes,
@ -153,7 +153,8 @@ export function buildConfigFromNodeAttrs(
return {
title: legacy.title ?? "",
dataSourceId: legacy.dataSourceId ?? null,
visualizationType: legacy.type ?? legacy.visualizationType ?? null,
visualizationType:
legacy.type ?? legacy.visualizationType ?? GenericQueryChartType.Line,
queries: legacy.queries ?? null,
timeRange: legacy.timeRange ?? null,
refreshInterval: legacy.refreshInterval ?? null,
@ -178,7 +179,7 @@ export function buildConfigFromNodeAttrs(
dataSourceId: (attrs.dataSourceId as string | null | undefined) ?? null,
visualizationType:
(attrs.visualizationType as GenericQueryChartType | null | undefined) ??
null,
GenericQueryChartType.Line,
queries: (attrs.queries as MetricConfig["queries"] | undefined) ?? null,
timeRange: (attrs.timeRange as TimeRangePreset | null | undefined) ?? null,
refreshInterval:

View File

@ -174,23 +174,6 @@ describe("indexToAlphabeticLabel", () => {
})
describe("mergeVisualizationResults", () => {
it.for([
{ name: "null", type: null },
{ name: "undefined", type: undefined },
])(
"reports type-not-selected when the chart type is $name",
({ type }, { expect }) => {
expect(
mergeVisualizationResults(
type,
[queryItem(okResult([]))],
null,
orderedLabel,
),
).toEqual({ status: GenericQueryResultStatus.TypeNotSelected })
},
)
it.for([
{ name: "null", results: null },
{ name: "undefined", results: undefined },

View File

@ -102,7 +102,7 @@ function truncToDecimals(num: number, decimals: number | null) {
}
function extractVisualizationData(
visualizationType: GenericQueryChartType | null | undefined,
visualizationType: GenericQueryChartType,
input: GenericQueryResult | null | undefined,
legendLabelFormat: string,
decimals: number | null,
@ -112,10 +112,6 @@ function extractVisualizationData(
queryErrorMessage?: string
data?: MultipleLineChartData | MultipleBarChartData | MultipleGaugeChartData
} {
if (!visualizationType) {
return { status: GenericQueryResultStatus.TypeNotSelected }
}
if (!input) {
return { status: GenericQueryResultStatus.NoData }
}
@ -227,7 +223,7 @@ export interface QueryResultItem {
}
export function mergeVisualizationResults(
visualizationType: GenericQueryChartType | null | undefined,
visualizationType: GenericQueryChartType,
queryResults: QueryResultItem[] | null | undefined,
decimals: number | null,
orderedLegendLabelFn: (type: GenericQueryChartType) => string,
@ -236,10 +232,6 @@ export function mergeVisualizationResults(
queryErrorMessage?: string
data?: MultipleLineChartData | MultipleBarChartData | MultipleGaugeChartData
} {
if (!visualizationType) {
return { status: GenericQueryResultStatus.TypeNotSelected }
}
if (!queryResults?.length) {
return { status: GenericQueryResultStatus.NoData }
}

View File

@ -20,7 +20,7 @@ function metricConfig(title: string): MetricConfig {
return {
title,
dataSourceId: null,
visualizationType: null,
visualizationType: GenericQueryChartType.Line,
queries: null,
timeRange: null,
refreshInterval: null,

View File

@ -437,12 +437,6 @@
"simplified-config-in-progress": {
"title": "No Data"
},
"type-not-selected": {
"title": "No Visualization Selected",
"description": "Start configuring metrics by selecting a visualization type",
"normal-action-button": "Edit Metrics",
"readonly-action-button": "View Metrics"
},
"data-source-not-selected": {
"title": "No Data Source Selected",
"description": "Choose a data source to see metric data",
@ -464,7 +458,7 @@
"description": "The current configuration returned no data. Please adjust your queries or data sources",
"normal-action-button": "Edit Metrics",
"readonly-action-button": "View Metrics",
"simulate-action-button": "Simulate data"
"simulate-action-button": "Simulate Data"
}
},
"config": {
@ -783,12 +777,12 @@
}
},
"simulation": {
"label": "Simulation",
"tooltip": "Showing simulated data until real data is available",
"section-title": "Simulated data",
"section-description": "Simulate the shape this chart will take until the metric it documents becomes available.",
"preset-label": "Chart",
"preset-placeholder": "Select a chart",
"stop-button": "Stop simulating",
"section-description": "The chart shows sample data in the pattern below until real data is available.",
"preset-label": "Data pattern",
"preset-placeholder": "Select a pattern",
"stop-button": "Remove Simulated Data",
"preset-options": {
"cpu_usage": "CPU usage",
"memory_usage": "Memory usage",