Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Server side env var interpolation #285

Open
wants to merge 25 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
dd2cd88
feat: envVariables subMenu and editor decorations
Jan 16, 2024
ada9fa3
Merge branch 'main' of github.com:dash0hq/otelbin into server-side-en…
Jan 16, 2024
7a2dfcf
fix: sonarCloud issue
Jan 16, 2024
6b21d06
fix: envVar form style for Safari
Jan 16, 2024
53977a8
fix: envVar data
Jan 17, 2024
a7b7a63
fix: duplicate import
Jan 17, 2024
9b4ebbe
fix: editor decoration unstable, envVars conditions
Jan 18, 2024
22889f0
Merge branch 'main' of github.com:dash0hq/otelbin into server-side-en…
Jan 18, 2024
2167c40
fix: test for extractVariables, set empty string for multiple default…
Jan 19, 2024
2ba4505
fix: sonarCloud bug
Jan 19, 2024
bdb9876
fix: line number changes with different conditions of envVar and defa…
Jan 19, 2024
532dc4a
fix: refactor extractEnvData
Jan 21, 2024
d84fcab
Merge branch 'main' of github.com:dash0hq/otelbin into server-side-en…
Jan 21, 2024
e3cb552
fix: remove commented codes
Jan 21, 2024
2ab1f94
fix: refactor envVarState name, minor improvements
Jan 22, 2024
8c8866d
Merge branch 'main' of github.com:dash0hq/otelbin into server-side-en…
Jan 22, 2024
54e9ebc
fix: lines number and re-render bug
Jan 24, 2024
5b23261
fix: default values
Jan 24, 2024
fa9343c
Merge branch 'main' of github.com:dash0hq/otelbin into server-side-en…
Jan 24, 2024
e5a5f5f
fix: improve editor decorations
Jan 24, 2024
0e94c11
fix: various improvements and fix bugs
Jan 25, 2024
118cfb1
fix: decorations after paste
Jan 25, 2024
a9c4e92
fix: default value on create a new envVar
Jan 25, 2024
3bd85bd
fix: minor fix
Jan 25, 2024
4a86382
fix: improve handling empty values
Jan 25, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/otelbin/.eslintignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
src/lib/urlState/jsurl2.ts
src/lib/urlState/jsurl2.ts
src/components/textArea.tsx
24 changes: 24 additions & 0 deletions packages/otelbin/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/otelbin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@monaco-editor/react": "^4.6.0",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
Expand Down
153 changes: 153 additions & 0 deletions packages/otelbin/src/components/EnvVarForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// SPDX-FileCopyrightText: 2023 Dash0 Inc.
// SPDX-License-Identifier: Apache-2.0

import React, { useEffect, useMemo, useRef, useState } from "react";
import { type IEnvVar, useEnvVarMenu, useEnvLines, type ILine } from "~/contexts/EditorContext";
import { IconButton } from "./icon-button";
import { Check, X, XCircle } from "lucide-react";
import { Label } from "./label";
import { Textarea } from "./textArea";
import { useUrlState } from "~/lib/urlState/client/useUrlState";
import { envVarBinding } from "./validation/binding";
import { editorBinding } from "./monaco-editor/editorBinding";
import { extractEnvVarData, extractVariables } from "./monaco-editor/parseYaml";

export default function EnvVarForm() {
const { openEnvVarMenu, setOpenEnvVarMenu } = useEnvVarMenu();
const { envVarLine } = useEnvLines();
const [{ env, config }] = useUrlState([editorBinding, envVarBinding]);
const variables = useMemo(() => extractVariables(config), [config]);
const envVarData = extractEnvVarData(variables, env);
const [envVarDataState, setEnvVarDataState] = useState(envVarData);
function handleClose() {
setOpenEnvVarMenu(false);
}

const unboundVariables = Object.values(envVarDataState).filter(
(envVar) => envVar.submittedValue === undefined && envVar.defaultValue === ""
);

useEffect(() => {
setEnvVarDataState(extractEnvVarData(variables, env));
}, [variables, env]);

return (
<div
style={{
width: openEnvVarMenu ? `${400}px` : 0,
maxWidth: openEnvVarMenu ? `${400}px` : 0,
transition: "all 0.2s ease-in-out",
}}
className="shrink-0 bg-default shadow-none border-b-default border-r overflow-hidden overflow-y-auto"
>
<div className="w-[400px] flex flex-col h-full">
<div className="flex justify-between items-center px-4 pl-4 pr-1 py-[4.5px] shadow-none border-b-default border-b">
<div className="text-sm text-default">
<span
style={{
color: unboundVariables.length > 0 ? "#F87171" : "#69F18E",
}}
>
{unboundVariables.length}
</span>{" "}
{`${unboundVariables.length === 1 ? "variable" : "variables"} unbound`}
</div>
<IconButton onClick={handleClose} variant={"transparent"} size={"xs"}>
<X height={12} />
</IconButton>
</div>
<div className="px-4">
{Object.values(envVarDataState).map((envVar) => (
<EnvVar key={envVar.name} envVar={envVar} lines={envVarLine[envVar.name]} />
))}
</div>
</div>
</div>
);
}

function EnvVar({ envVar, lines }: { envVar: IEnvVar; lines?: ILine }) {
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const [{ env }, getLink] = useUrlState([envVarBinding, editorBinding]);
const [envVarValue, setEnvVarValue] = useState(env[envVar.name] ?? envVar.defaultValue ?? "");

function handleEnvVarChange(event: React.ChangeEvent<HTMLTextAreaElement>) {
setEnvVarValue(event.target.value);
}

function handleEnvVarSubmit() {
if (typeof window !== "undefined") {
window.history.pushState(null, "", getLink({ env: { ...env, [envVar.name]: envVarValue } }));
}
}

useEffect(() => {
//To enable automatic resizing of the textarea
if (textAreaRef.current) {
textAreaRef.current.style.height = "0px";
const scrollHeight = textAreaRef.current.scrollHeight;
textAreaRef.current.style.height = scrollHeight + "px";
}
}, [envVarValue]);

useEffect(() => {
if (envVar.defaultValue === "") {
setEnvVarValue(env[envVar.name] ?? "");
} else if (envVar.defaultValue !== "" && envVar.defaultValue !== undefined) {
setEnvVarValue(env[envVar.name] ?? envVar.defaultValue ?? "");
}
}, [env, envVar.defaultValue, envVar.name]);

return (
<div className="flex flex-col gap-y-1 my-6">
<div className="flex flex-col w-full gap-1.5 h-full">
<div className="flex gap-x-1 items-center">
<Label htmlFor="envVar">{envVar.name}</Label>
{envVarValue === env[envVar.name] && <Check height={14} color={"#69F18E"} />}
</div>
<div className="relative">
<Textarea
value={envVarValue}
ref={textAreaRef}
onChange={handleEnvVarChange}
className="placeholder:italic h-[35px] min-h-[35px] max-h-[100px] overflow-hidden resize-none w-full pr-10"
id="envVar"
placeholder={env[envVar.name] === "" ? "empty" : "enter value"}
/>
{envVarValue === env[envVar.name] ? (
<IconButton
onClick={() => {
setEnvVarValue("");
}}
variant={"transparent"}
size={"xs"}
className="absolute right-2 top-[6px] z-10"
>
<XCircle height={16} />
</IconButton>
) : (
<IconButton
onClick={handleEnvVarSubmit}
variant={"transparent"}
size={"xs"}
className="absolute right-2 top-[6px] z-10"
>
<Check height={16} />
</IconButton>
)}
</div>
</div>
{lines && lines.lines.length > 0 && (
<Label className="text-[12px] text-[#AFAFB2]" htmlFor="envVar">
{`Used ${lines && lines.lines.length} ${lines && lines?.lines.length > 1 ? `times` : `time`} on line `}
{lines?.lines?.map((lineNumber, index) => (
<React.Fragment key={lineNumber}>
<span className="text-blue-400">{lineNumber}</span>
{index < (lines.lines?.length ?? 0) - 1 ? ` and ` : ``}
</React.Fragment>
))}
</Label>
)}
</div>
);
}
22 changes: 22 additions & 0 deletions packages/otelbin/src/components/label.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// SPDX-FileCopyrightText: 2023 Dash0 Inc.
// SPDX-License-Identifier: Apache-2.0

"use client";

import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";

import { cn } from "~/lib/utils";

const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70");

const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;

export { Label };
11 changes: 8 additions & 3 deletions packages/otelbin/src/components/monaco-editor/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ import { IconButton } from "~/components/icon-button";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/tooltip";
import { track } from "@vercel/analytics";
import { useServerSideValidation } from "../validation/useServerSideValidation";
import { selectConfigType } from "./parseYaml";
import { extractEnvVarData, extractVariables, selectConfigType } from "./parseYaml";
import EnvVarForm from "../EnvVarForm";
import { envVarBinding } from "../validation/binding";

const firaCode = Fira_Code({
display: "swap",
Expand All @@ -45,10 +47,12 @@ export default function Editor({ locked, setLocked }: { locked: boolean; setLock
const { setViewMode, viewMode } = useViewMode();
const savedOpenModal = Boolean(typeof window !== "undefined" && localStorage.getItem("welcomeModal"));
const [openDialog, setOpenDialog] = useState(savedOpenModal ? !savedOpenModal : true);
const [{ config }, getLink] = useUrlState([editorBinding]);
const [{ env, config }, getLink] = useUrlState([editorBinding, envVarBinding]);
const [currentConfig, setCurrentConfig] = useState<string>(config);
const clerk = useClerk();
const serverSideValidationResult = useServerSideValidation();
const variables = useMemo(() => extractVariables(config), [config]);
const envVarData = extractEnvVarData(variables, env);
const serverSideValidationResult = useServerSideValidation(envVarData);
const isServerValidationEnabled = useServerSideValidationEnabled();
const onWidthChange = useCallback((newWidth: number) => {
localStorage.setItem("width", String(newWidth));
Expand Down Expand Up @@ -194,6 +198,7 @@ export default function Editor({ locked, setLocked }: { locked: boolean; setLock
{viewMode !== "pipeline" && <ValidationErrorConsole errors={totalValidationErrors} font={firaCode} />}
{viewMode == "both" && <ResizeBar onWidthChange={onWidthChange} />}
</div>
{Object.keys(envVarData).length > 0 && <EnvVarForm />}
<div className="z-0 min-h-full w-full shrink grow relative">
<AutoSizer>
{({ width, height }) => (
Expand Down
47 changes: 46 additions & 1 deletion packages/otelbin/src/components/monaco-editor/parseYaml.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@

import { describe, expect, it } from "@jest/globals";
import type { IItem, IYamlElement } from "./parseYaml";
import { getYamlDocument, extractServiceItems, findPipelinesKeyValues, parseYaml, selectConfigType } from "./parseYaml";
import {
getYamlDocument,
extractServiceItems,
findPipelinesKeyValues,
parseYaml,
selectConfigType,
extractVariables,
} from "./parseYaml";

//The example contains pipelines with duplicated names (otlp and batch)
const editorBinding = {
Expand Down Expand Up @@ -245,3 +252,41 @@ describe("selectConfigType", () => {
expect(selectConfigType(config)).toBe(config);
});
});

describe("extractVariables", () => {
//The example contains 2 environment variables, one with default value and one without
const inputString = {
prefix: "",
name: "config",
fallback: `
receivers:
otlp:
endpoint: \${env1:defaultValue1}:14250
processors:
batch:
endpoint: \${env2}:14250
service:
extensions: [health_check, pprof, zpages]
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlp]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [otlp]
`
.trim()
.replaceAll(/\t/g, " ") as string,
} as const;

it("should extract variables from input string", () => {
const result1 = extractVariables(inputString.fallback);
expect(result1).toEqual(["${env1:defaultValue1}", "${env2}"]);

const inputString2 = "No variables in this string";
const result2 = extractVariables(inputString2);
expect(result2).toEqual([]);
});
});
32 changes: 32 additions & 0 deletions packages/otelbin/src/components/monaco-editor/parseYaml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

import YAML, { Parser } from "yaml";
import type { IEnvVar } from "~/contexts/EditorContext";
export interface SourceToken {
type:
| "byte-order-mark"
Expand Down Expand Up @@ -253,3 +254,34 @@ export function selectConfigType(config: string) {
return config;
}
}

export function extractVariables(inputString: string): string[] {
const variableRegex = /\${([^}]+)}/g;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spec of the env var detection is here: #156

const matches = inputString.match(variableRegex);

return matches ? matches.map((match) => match) : [];
}

export function extractEnvVarData(envVars: string[], envUrlState: Record<string, string>) {
const envVarData: Record<string, IEnvVar> = {};

if (envVars && envVars.length > 0) {
const envVarPlaceHolder = envVars.map((variable) => variable.slice(2, -1));

envVarPlaceHolder.forEach((variable) => {
const name = variable.split(":")[0] ?? variable;
const defaultValue: string | undefined = variable.split(":")[1] ?? "";
const distinctDefaultValues = new Set([...(envVarData[name]?.defaultValues ?? []), defaultValue]);
const submittedValue = envUrlState[name];

envVarData[name] = {
name: name,
submittedValue: submittedValue,
defaultValues: [...distinctDefaultValues],
defaultValue: [...distinctDefaultValues].length > 1 ? "" : defaultValue,
};
});
}

return envVarData;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,8 @@
background-color: #38bdf8 !important;
color: black !important;
}

.envVarDecoration {
color: #fb923c !important;
cursor: pointer;
}
24 changes: 24 additions & 0 deletions packages/otelbin/src/components/textArea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// SPDX-FileCopyrightText: 2023 Dash0 Inc.
// SPDX-License-Identifier: Apache-2.0

import * as React from "react";

import { cn } from "~/lib/utils";

export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}

const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
});
Textarea.displayName = "Textarea";

export { Textarea };
Loading
Loading