- Bumped versions of several packages across the codebase, including: - Updated `@supabase/supabase-js` from `2.51.0` to `2.52.0` - Incremented `next` from `15.4.1` to `15.4.2` - Updated `@types/node` from `^24.0.13` to `^24.0.15` - Upgraded `ai` from `4.3.18` to `4.3.19` - Updated `@next/bundle-analyzer` from `15.4.1` to `15.4.2` - Incremented `@next/eslint-plugin-next` from `15.4.1` to `15.4.2` - Bumped `eslint-config-next` from `15.3.5` to `15.4.2` - Updated `@kit/prettier-config` and other related packages for consistency and compatibility improvements.
999 lines
30 KiB
TypeScript
999 lines
30 KiB
TypeScript
'use client';
|
|
|
|
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
|
|
|
|
import Link from 'next/link';
|
|
import { useRouter, useSearchParams } from 'next/navigation';
|
|
|
|
import { envVariables } from '@/app/variables/lib/env-variables-model';
|
|
import { updateEnvironmentVariableAction } from '@/app/variables/lib/server-actions';
|
|
import { EnvModeSelector } from '@/components/env-mode-selector';
|
|
import {
|
|
ChevronsUpDownIcon,
|
|
Copy,
|
|
CopyIcon,
|
|
Eye,
|
|
EyeOff,
|
|
EyeOffIcon,
|
|
InfoIcon,
|
|
TriangleAlertIcon,
|
|
} from 'lucide-react';
|
|
import { Subject, debounceTime } from 'rxjs';
|
|
|
|
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
|
import { Badge } from '@kit/ui/badge';
|
|
import { Button } from '@kit/ui/button';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuCheckboxItem,
|
|
DropdownMenuContent,
|
|
DropdownMenuTrigger,
|
|
} from '@kit/ui/dropdown-menu';
|
|
import { Heading } from '@kit/ui/heading';
|
|
import { If } from '@kit/ui/if';
|
|
import { Input } from '@kit/ui/input';
|
|
import { toast } from '@kit/ui/sonner';
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from '@kit/ui/tooltip';
|
|
import { cn } from '@kit/ui/utils';
|
|
|
|
import { AppEnvState, EnvVariableState } from '../lib/types';
|
|
import { DynamicFormInput } from './dynamic-form-input';
|
|
|
|
export function AppEnvironmentVariablesManager({
|
|
state,
|
|
}: React.PropsWithChildren<{
|
|
state: AppEnvState;
|
|
}>) {
|
|
return <EnvList appState={state} />;
|
|
}
|
|
|
|
function EnvListDisplay({
|
|
groups,
|
|
className,
|
|
hideSecret = false,
|
|
}: {
|
|
groups: Array<{
|
|
category: string;
|
|
variables: Array<EnvVariableState>;
|
|
}>;
|
|
|
|
className: string;
|
|
hideSecret?: boolean;
|
|
}) {
|
|
return (
|
|
<div className={cn(className)}>
|
|
<div
|
|
className={
|
|
'text-muted-foreground relative flex h-full flex-col rounded-lg font-mono text-xs'
|
|
}
|
|
>
|
|
<div className="bg-muted/50 sticky top-0 flex flex-col gap-y-1 rounded-lg p-4">
|
|
<div className={'sticky top-0 h-full overflow-auto pb-16 break-all'}>
|
|
{groups.map((group) => (
|
|
<div className="mb-4" key={group.category}>
|
|
<span># {group.category}</span>
|
|
|
|
{group.variables.map((variable) => {
|
|
const model = envVariables.find(
|
|
(item) => item.name === variable.key,
|
|
);
|
|
|
|
const isSecret = model?.secret;
|
|
const value =
|
|
isSecret && hideSecret
|
|
? '••••••••'
|
|
: variable.effectiveValue;
|
|
|
|
return (
|
|
<Link
|
|
href={`#var_${variable.key.toLowerCase()}`}
|
|
className={cn('block transition-all hover:underline', {
|
|
['text-orange-500']: variable.isOverridden,
|
|
['text-destructive']: !variable.validation.success,
|
|
['opacity-20']: !variable.isVisible,
|
|
})}
|
|
key={variable.key}
|
|
>
|
|
<span>{variable.key}</span>: {value}
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EnvList({ appState }: { appState: AppEnvState }) {
|
|
const [showValues, setShowValues] = useState<Record<string, boolean>>({});
|
|
const [search, setSearch] = useState('');
|
|
const searchParams = useSearchParams();
|
|
|
|
const showSecretVars = searchParams.get('secret') === 'true';
|
|
const showPublicVars = searchParams.get('public') === 'true';
|
|
const showPrivateVars = searchParams.get('private') === 'true';
|
|
const showOverriddenVars = searchParams.get('overridden') === 'true';
|
|
const showInvalidVars = searchParams.get('invalid') === 'true';
|
|
const showDeprecatedVars = searchParams.get('deprecated') === 'true';
|
|
|
|
const toggleShowValue = (key: string) => {
|
|
setShowValues((prev) => ({
|
|
...prev,
|
|
[key]: !prev[key],
|
|
}));
|
|
};
|
|
|
|
const renderValue = (value: string, isVisible: boolean) => {
|
|
if (!isVisible) {
|
|
if (!value) {
|
|
return `(empty)`;
|
|
}
|
|
|
|
return '••••••••';
|
|
}
|
|
|
|
return value;
|
|
};
|
|
|
|
const allVariables = getEffectiveVariablesValue(appState);
|
|
|
|
const subject$ = useMemo(
|
|
() =>
|
|
new Subject<{
|
|
name: string;
|
|
value: string;
|
|
}>(),
|
|
[],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const subscription = subject$
|
|
.pipe(debounceTime(1000))
|
|
.subscribe((props) => {
|
|
updateEnvironmentVariableAction({
|
|
...props,
|
|
mode: appState.mode,
|
|
})
|
|
.then((result) => {
|
|
toast.success(result.message);
|
|
})
|
|
.catch((err) => {
|
|
toast.error(`Failed to update ${props.name}: ${err.message}`);
|
|
});
|
|
});
|
|
|
|
return () => {
|
|
return subscription.unsubscribe();
|
|
};
|
|
}, [subject$]);
|
|
|
|
const onValueChanged = useCallback(
|
|
(props: { value: string; name: string }) => {
|
|
subject$.next({
|
|
name: props.name,
|
|
value: props.value,
|
|
});
|
|
},
|
|
[subject$],
|
|
);
|
|
|
|
const renderVariable = (varState: EnvVariableState) => {
|
|
const model = envVariables.find(
|
|
(variable) => variable.name === varState.key,
|
|
);
|
|
|
|
const isClientBundledValue = varState.key.startsWith('NEXT_PUBLIC_');
|
|
const isValueVisible = showValues[varState.key] ?? !model?.secret;
|
|
|
|
return (
|
|
<div
|
|
id={`var_${varState.key.toLowerCase()}`}
|
|
key={varState.key}
|
|
className={cn('animate-in fade-in py-6 transition-all', {
|
|
hidden: !varState.isVisible,
|
|
})}
|
|
>
|
|
<div className={'flex flex-col space-y-2'}>
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex max-w-full flex-1 flex-col">
|
|
<div className="flex items-center gap-4">
|
|
<span
|
|
className={cn('font-mono text-sm font-semibold', {
|
|
'text-orange-500': varState.isOverridden,
|
|
'text-destructive': !varState.validation.success,
|
|
})}
|
|
>
|
|
{varState.key}
|
|
</span>
|
|
|
|
{model?.required && <Badge variant="outline">Required</Badge>}
|
|
|
|
{varState.effectiveSource === 'MISSING' && (
|
|
<Badge
|
|
variant={
|
|
// Show destructive if required OR if contextual validation dependencies are not met
|
|
model?.required ||
|
|
model?.contextualValidation?.dependencies.some((dep) => {
|
|
const dependencyValue =
|
|
allVariables[dep.variable] ?? '';
|
|
|
|
const shouldValidate = dep.condition(
|
|
dependencyValue,
|
|
allVariables,
|
|
);
|
|
|
|
if (!shouldValidate) {
|
|
return false;
|
|
}
|
|
|
|
return !model.contextualValidation!.validate({
|
|
value: varState.effectiveValue,
|
|
variables: allVariables,
|
|
mode: appState.mode,
|
|
}).success;
|
|
})
|
|
? 'destructive'
|
|
: 'outline'
|
|
}
|
|
>
|
|
Missing
|
|
</Badge>
|
|
)}
|
|
|
|
{varState.isOverridden && (
|
|
<Badge variant="warning">Overridden</Badge>
|
|
)}
|
|
</div>
|
|
|
|
<If condition={model}>
|
|
{(model) => (
|
|
<div className="flex items-center gap-2 py-1">
|
|
<span className="text-muted-foreground text-xs font-normal">
|
|
{model.description}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</If>
|
|
|
|
<div className="mt-2 flex items-center gap-2">
|
|
<If
|
|
condition={isValueVisible || !varState.effectiveValue}
|
|
fallback={
|
|
<div className="max-w-auto bg-muted text-muted-foreground flex h-9 w-auto flex-1 items-center overflow-x-auto rounded border px-2 py-2 font-mono text-xs">
|
|
{renderValue(varState.effectiveValue, isValueVisible)}
|
|
</div>
|
|
}
|
|
>
|
|
<DynamicFormInput
|
|
type={model?.type ?? 'string'}
|
|
name={varState.key}
|
|
value={varState.effectiveValue}
|
|
onChange={onValueChanged}
|
|
placeholder={`Set a value for ${varState.key}`}
|
|
enumValues={model?.values}
|
|
className="text-xs"
|
|
/>
|
|
</If>
|
|
|
|
<If condition={model?.secret}>
|
|
<Button
|
|
variant="ghost"
|
|
size={'icon'}
|
|
onClick={() => toggleShowValue(varState.key)}
|
|
>
|
|
{isValueVisible ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
</Button>
|
|
</If>
|
|
|
|
<If condition={model && model.type !== 'boolean'}>
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => copyToClipboard(varState.effectiveValue)}
|
|
size={'icon'}
|
|
>
|
|
<Copy size={16} />
|
|
</Button>
|
|
</If>
|
|
</div>
|
|
|
|
<If condition={model?.hint}>
|
|
{(hint) => (
|
|
<div className="mt-2 flex items-center gap-2">
|
|
<span className="text-muted-foreground text-xs font-normal">
|
|
{hint}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</If>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-2 flex gap-x-2">
|
|
<Badge
|
|
variant="outline"
|
|
className={cn({
|
|
'text-orange-500': !isClientBundledValue,
|
|
'text-green-500': isClientBundledValue,
|
|
})}
|
|
>
|
|
{isClientBundledValue ? `Public variable` : `Private variable`}
|
|
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger>
|
|
<InfoIcon className="ml-2 h-3 w-3" />
|
|
</TooltipTrigger>
|
|
|
|
<TooltipContent>
|
|
{isClientBundledValue
|
|
? `This variable will be bundled into the client side. If this is a private variable, do not use "NEXT_PUBLIC".`
|
|
: `This variable is private and will not be bundled client side, so you cannot access it from React components rendered client side`}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</Badge>
|
|
|
|
<If condition={model?.secret}>
|
|
<Badge variant="outline" className={'text-destructive'}>
|
|
Secret Variable
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger>
|
|
<InfoIcon className="ml-2 h-3 w-3" />
|
|
</TooltipTrigger>
|
|
|
|
<TooltipContent>
|
|
This is a secret key. Keep it safe!
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</Badge>
|
|
</If>
|
|
|
|
<If condition={varState.effectiveSource !== 'MISSING'}>
|
|
<Badge
|
|
variant={'outline'}
|
|
className={cn({
|
|
'text-destructive':
|
|
varState.effectiveSource === '.env.production',
|
|
})}
|
|
>
|
|
{varState.effectiveSource}
|
|
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger>
|
|
<InfoIcon className="ml-2 h-3 w-3" />
|
|
</TooltipTrigger>
|
|
|
|
<TooltipContent>
|
|
{varState.effectiveSource === '.env.local'
|
|
? `These variables are specific to this machine and are not committed`
|
|
: varState.effectiveSource === '.env.development'
|
|
? `These variables are only being used during development`
|
|
: varState.effectiveSource === '.env'
|
|
? `These variables are shared under all modes`
|
|
: `These variables are only used in production mode`}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</Badge>
|
|
</If>
|
|
|
|
<If condition={varState.isOverridden}>
|
|
<Badge variant="warning">
|
|
Overridden in {varState.effectiveSource}
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger>
|
|
<InfoIcon className="ml-2 h-3 w-3" />
|
|
</TooltipTrigger>
|
|
|
|
<TooltipContent>
|
|
This variable was overridden by a variable in{' '}
|
|
{varState.effectiveSource}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</Badge>
|
|
</If>
|
|
|
|
<If condition={!varState.validation.success}>
|
|
<Badge variant="destructive">
|
|
Invalid Value
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger>
|
|
<InfoIcon className="ml-2 h-3 w-3" />
|
|
</TooltipTrigger>
|
|
|
|
<TooltipContent>
|
|
This variable has an invalid value. Drop down to view the
|
|
errors.
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</Badge>
|
|
</If>
|
|
|
|
<If condition={model?.deprecated}>
|
|
{(deprecated) => (
|
|
<Badge variant="warning">
|
|
Deprecated
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger>
|
|
<TriangleAlertIcon className="ml-2 h-3 w-3" />
|
|
</TooltipTrigger>
|
|
|
|
<TooltipContent>
|
|
<div className="space-y-2">
|
|
<div className="font-medium">
|
|
This variable is deprecated
|
|
</div>
|
|
<div className="text-sm">
|
|
<strong>Reason:</strong> {deprecated.reason}
|
|
</div>
|
|
{deprecated.alternative && (
|
|
<div className="text-sm">
|
|
<strong>Use instead:</strong>{' '}
|
|
{deprecated.alternative}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</Badge>
|
|
)}
|
|
</If>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex w-full flex-col gap-y-2 py-4">
|
|
<If condition={!varState.validation.success}>
|
|
<div className={'flex flex-col space-y-2'}>
|
|
<Alert variant="destructive">
|
|
<AlertTitle>
|
|
{varState.effectiveSource === 'MISSING'
|
|
? `The variable ${varState.key} is required but missing`
|
|
: `The value for ${varState.key} is invalid`}
|
|
</AlertTitle>
|
|
|
|
<AlertDescription>
|
|
<div className="space-y-2">
|
|
<div className="space-y-1">
|
|
{varState.validation.error?.issues.map((issue, index) => (
|
|
<div key={index} className="text-sm">
|
|
• {issue}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Display dependency information if available */}
|
|
{model?.contextualValidation?.dependencies && (
|
|
<div className="mt-4 space-y-1">
|
|
<div className="font-medium">Dependencies:</div>
|
|
|
|
{model.contextualValidation.dependencies.map(
|
|
(dep, index) => (
|
|
<div key={index} className="text-sm">
|
|
• Requires valid {dep.variable.toUpperCase()} when{' '}
|
|
{dep.message}
|
|
</div>
|
|
),
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</AlertDescription>
|
|
</Alert>
|
|
</div>
|
|
</If>
|
|
|
|
<If condition={varState.definitions.length > 1}>
|
|
<div className={'flex flex-col space-y-2'}>
|
|
<Heading level={6} className="text-sm font-medium">
|
|
Override Chain
|
|
</Heading>
|
|
|
|
<div className="w-full space-y-2">
|
|
{varState.definitions.map((def) => (
|
|
<div
|
|
key={`${def.key}-${def.source}`}
|
|
className="flex items-center gap-2"
|
|
>
|
|
<Badge
|
|
variant={'outline'}
|
|
className={cn({
|
|
'text-destructive': def.source === '.env.production',
|
|
})}
|
|
>
|
|
{def.source}
|
|
</Badge>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</If>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const filterVariable = (varState: EnvVariableState) => {
|
|
const model = envVariables.find(
|
|
(variable) => variable.name === varState.key,
|
|
);
|
|
|
|
if (
|
|
!search &&
|
|
!showSecretVars &&
|
|
!showPublicVars &&
|
|
!showPrivateVars &&
|
|
!showInvalidVars &&
|
|
!showOverriddenVars &&
|
|
!showDeprecatedVars
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
const isSecret = model?.secret ?? false;
|
|
const isPublic = varState.key.startsWith('NEXT_PUBLIC_');
|
|
const isPrivate = !isPublic;
|
|
|
|
const isInSearch = search
|
|
? varState.key.toLowerCase().includes(search.toLowerCase())
|
|
: true;
|
|
|
|
if (showPublicVars && isInSearch) {
|
|
return isPublic;
|
|
}
|
|
|
|
if (showSecretVars && isInSearch) {
|
|
return isSecret;
|
|
}
|
|
|
|
if (showPrivateVars && isInSearch) {
|
|
return isPrivate;
|
|
}
|
|
|
|
if (showOverriddenVars && isInSearch) {
|
|
return varState.isOverridden;
|
|
}
|
|
|
|
if (showInvalidVars && isInSearch) {
|
|
return !varState.validation.success;
|
|
}
|
|
|
|
if (showDeprecatedVars && isInSearch) {
|
|
return !!model?.deprecated;
|
|
}
|
|
|
|
return isInSearch;
|
|
};
|
|
|
|
// Update groups to use allVarsWithValidation instead of appState.variables
|
|
const groups = getGroups(appState, filterVariable);
|
|
|
|
return (
|
|
<div className="flex h-full flex-1 flex-col gap-y-4">
|
|
<div className="flex items-center">
|
|
<div className="flex w-full space-x-2 py-0.5">
|
|
<div>
|
|
<EnvModeSelector mode={appState.mode} />
|
|
</div>
|
|
|
|
<div>
|
|
<FilterSwitcher
|
|
filters={{
|
|
secret: showSecretVars,
|
|
public: showPublicVars,
|
|
overridden: showOverriddenVars,
|
|
private: showPrivateVars,
|
|
invalid: showInvalidVars,
|
|
deprecated: showDeprecatedVars,
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<Input
|
|
className={'w-full'}
|
|
placeholder="Search variables"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-1 flex-col gap-y-4 overflow-hidden">
|
|
<Summary appState={appState} />
|
|
|
|
<div className="flex w-full flex-1 space-x-4 overflow-hidden">
|
|
<div className="flex w-6/12 flex-1 flex-col overflow-y-auto">
|
|
<div className="flex flex-col gap-y-4">
|
|
{groups.map((group) => {
|
|
const visibleVariables = group.variables.filter(
|
|
(item) => item.isVisible,
|
|
);
|
|
|
|
if (visibleVariables.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={group.category}
|
|
className="flex flex-col rounded-lg border p-4"
|
|
>
|
|
<div>
|
|
<span className={'text-lg font-bold'}>
|
|
{group.category}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex flex-col">
|
|
{group.variables.map((item) => {
|
|
return (
|
|
<Fragment key={item.key}>
|
|
{renderVariable(item)}
|
|
</Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
<If condition={groups.length === 0}>
|
|
<div className="flex h-full flex-1 flex-col items-center justify-center gap-y-4 py-16">
|
|
<div className="text-muted-foreground text-sm">
|
|
No variables found
|
|
</div>
|
|
</div>
|
|
</If>
|
|
</div>
|
|
</div>
|
|
|
|
<EnvListDisplay
|
|
className="sticky top-0 w-6/12 overflow-y-auto"
|
|
groups={groups}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FilterSwitcher(props: {
|
|
filters: {
|
|
secret: boolean;
|
|
public: boolean;
|
|
overridden: boolean;
|
|
private: boolean;
|
|
invalid: boolean;
|
|
deprecated: boolean;
|
|
};
|
|
}) {
|
|
const secretVars = props.filters.secret;
|
|
const publicVars = props.filters.public;
|
|
const overriddenVars = props.filters.overridden;
|
|
const privateVars = props.filters.private;
|
|
const invalidVars = props.filters.invalid;
|
|
const deprecatedVars = props.filters.deprecated;
|
|
|
|
const handleFilterChange = useUpdateFilteredVariables();
|
|
|
|
const buttonLabel = () => {
|
|
const filters = [];
|
|
|
|
if (secretVars) filters.push('Secret');
|
|
if (publicVars) filters.push('Public');
|
|
if (overriddenVars) filters.push('Overridden');
|
|
if (privateVars) filters.push('Private');
|
|
if (invalidVars) filters.push('Invalid');
|
|
if (deprecatedVars) filters.push('Deprecated');
|
|
|
|
if (filters.length === 0) return 'Filter variables';
|
|
|
|
return filters.join(', ');
|
|
};
|
|
|
|
const allSelected =
|
|
!secretVars &&
|
|
!publicVars &&
|
|
!overriddenVars &&
|
|
!invalidVars &&
|
|
!deprecatedVars;
|
|
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" className="font-normal">
|
|
{buttonLabel()}
|
|
|
|
<ChevronsUpDownIcon className="text-muted-foreground ml-1 h-3 w-3" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
|
|
<DropdownMenuContent>
|
|
<DropdownMenuCheckboxItem
|
|
checked={allSelected}
|
|
onCheckedChange={() => {
|
|
handleFilterChange('all', true);
|
|
}}
|
|
>
|
|
All
|
|
</DropdownMenuCheckboxItem>
|
|
|
|
<DropdownMenuCheckboxItem
|
|
checked={secretVars}
|
|
onCheckedChange={() => {
|
|
handleFilterChange('secret', !secretVars);
|
|
}}
|
|
>
|
|
Secret
|
|
</DropdownMenuCheckboxItem>
|
|
|
|
<DropdownMenuCheckboxItem
|
|
checked={privateVars}
|
|
onCheckedChange={() => {
|
|
handleFilterChange('private', !privateVars);
|
|
}}
|
|
>
|
|
Private
|
|
</DropdownMenuCheckboxItem>
|
|
|
|
<DropdownMenuCheckboxItem
|
|
checked={publicVars}
|
|
onCheckedChange={() => {
|
|
handleFilterChange('public', !publicVars);
|
|
}}
|
|
>
|
|
Public
|
|
</DropdownMenuCheckboxItem>
|
|
|
|
<DropdownMenuCheckboxItem
|
|
checked={invalidVars}
|
|
onCheckedChange={() => {
|
|
handleFilterChange('invalid', !invalidVars);
|
|
}}
|
|
>
|
|
Invalid
|
|
</DropdownMenuCheckboxItem>
|
|
|
|
<DropdownMenuCheckboxItem
|
|
checked={overriddenVars}
|
|
onCheckedChange={() => {
|
|
handleFilterChange('overridden', !overriddenVars);
|
|
}}
|
|
>
|
|
Overridden
|
|
</DropdownMenuCheckboxItem>
|
|
|
|
<DropdownMenuCheckboxItem
|
|
checked={deprecatedVars}
|
|
onCheckedChange={() => {
|
|
handleFilterChange('deprecated', !deprecatedVars);
|
|
}}
|
|
>
|
|
Deprecated
|
|
</DropdownMenuCheckboxItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|
|
|
|
function Summary({ appState }: { appState: AppEnvState }) {
|
|
const varsArray = Object.values(appState.variables);
|
|
const overridden = varsArray.filter((variable) => variable.isOverridden);
|
|
const handleFilterChange = useUpdateFilteredVariables();
|
|
|
|
// Find all variables with errors (including missing required and contextual validation)
|
|
const variablesWithErrors = varsArray.filter((variable) => {
|
|
return !variable.validation.success;
|
|
});
|
|
|
|
// Find deprecated variables
|
|
const deprecatedVariables = varsArray.filter((variable) => {
|
|
const model = envVariables.find((env) => env.name === variable.key);
|
|
return !!model?.deprecated;
|
|
});
|
|
|
|
const validVariables = varsArray.length - variablesWithErrors.length;
|
|
|
|
return (
|
|
<div className="flex justify-between space-x-4">
|
|
<div className="flex items-center gap-x-2">
|
|
<Badge variant={'outline'} className={'text-green-500'}>
|
|
{validVariables} Valid
|
|
</Badge>
|
|
|
|
<Badge
|
|
variant={'outline'}
|
|
className={cn({
|
|
'text-destructive': variablesWithErrors.length > 0,
|
|
'text-green-500': variablesWithErrors.length === 0,
|
|
})}
|
|
>
|
|
{variablesWithErrors.length} Invalid
|
|
</Badge>
|
|
|
|
<If condition={overridden.length > 0}>
|
|
<Badge
|
|
variant={'outline'}
|
|
className={cn({ 'text-orange-500': overridden.length > 0 })}
|
|
>
|
|
{overridden.length} Overridden
|
|
</Badge>
|
|
</If>
|
|
|
|
<If condition={deprecatedVariables.length > 0}>
|
|
<Badge
|
|
variant={'outline'}
|
|
className={cn({ 'text-amber-500': deprecatedVariables.length > 0 })}
|
|
>
|
|
{deprecatedVariables.length} Deprecated
|
|
</Badge>
|
|
</If>
|
|
</div>
|
|
|
|
<div className={'flex items-center gap-x-2'}>
|
|
<If condition={variablesWithErrors.length > 0}>
|
|
<Button
|
|
size={'sm'}
|
|
variant={'outline'}
|
|
onClick={() => handleFilterChange('invalid', true, true)}
|
|
>
|
|
<EyeOffIcon className="mr-2 h-3 w-3" />
|
|
Display Invalid only
|
|
</Button>
|
|
</If>
|
|
|
|
<If condition={deprecatedVariables.length > 0}>
|
|
<Button
|
|
size={'sm'}
|
|
variant={'outline'}
|
|
onClick={() => handleFilterChange('deprecated', true, true)}
|
|
>
|
|
<TriangleAlertIcon className="mr-2 h-3 w-3" />
|
|
Display Deprecated only
|
|
</Button>
|
|
</If>
|
|
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size={'sm'}
|
|
onClick={() => {
|
|
let data = '';
|
|
|
|
const groups = getGroups(appState, () => true);
|
|
|
|
groups.forEach((group) => {
|
|
data += `# ${group.category}\n`;
|
|
|
|
group.variables.forEach((variable) => {
|
|
data += `${variable.key}=${variable.effectiveValue}\n`;
|
|
});
|
|
|
|
data += '\n';
|
|
});
|
|
|
|
const promise = copyToClipboard(data);
|
|
|
|
toast.promise(promise, {
|
|
loading: 'Copying environment variables...',
|
|
success: 'Environment variables copied to clipboard.',
|
|
error: 'Failed to copy environment variables to clipboard',
|
|
});
|
|
}}
|
|
>
|
|
<CopyIcon className={'mr-2 h-4 w-4'} />
|
|
<span>Copy env file to clipboard</span>
|
|
</Button>
|
|
</TooltipTrigger>
|
|
|
|
<TooltipContent>
|
|
Copy environment variables to clipboard. You can place it in your
|
|
hosting provider to set up the full environment.
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function getEffectiveVariablesValue(
|
|
appState: AppEnvState,
|
|
): Record<string, string> {
|
|
const varsArray = Object.values(appState.variables);
|
|
|
|
return varsArray.reduce(
|
|
(acc, variable) => ({
|
|
...acc,
|
|
[variable.key]: variable.effectiveValue,
|
|
}),
|
|
{},
|
|
);
|
|
}
|
|
|
|
function useUpdateFilteredVariables() {
|
|
const router = useRouter();
|
|
|
|
const handleFilterChange = (key: string, value: boolean, reset = false) => {
|
|
const searchParams = new URLSearchParams(window.location.search);
|
|
const path = window.location.pathname;
|
|
|
|
const resetAll = () => {
|
|
searchParams.delete('secret');
|
|
searchParams.delete('public');
|
|
searchParams.delete('overridden');
|
|
searchParams.delete('private');
|
|
searchParams.delete('invalid');
|
|
searchParams.delete('deprecated');
|
|
};
|
|
|
|
if (reset) {
|
|
resetAll();
|
|
}
|
|
|
|
if (key === 'all' && value) {
|
|
resetAll();
|
|
} else {
|
|
if (!value) {
|
|
searchParams.delete(key);
|
|
} else {
|
|
searchParams.set(key, 'true');
|
|
}
|
|
}
|
|
|
|
router.push(`${path}?${searchParams.toString()}`);
|
|
};
|
|
|
|
return useCallback(handleFilterChange, [router]);
|
|
}
|
|
|
|
async function copyToClipboard(text: string) {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
} catch (err) {
|
|
console.error('Failed to copy:', err);
|
|
}
|
|
}
|
|
|
|
function getGroups(
|
|
appState: AppEnvState,
|
|
filterVariable: (variable: EnvVariableState) => boolean,
|
|
) {
|
|
return Object.values(appState.variables).reduce(
|
|
(acc, variable) => {
|
|
const group = acc.find((group) => group.category === variable.category);
|
|
variable.isVisible = filterVariable(variable);
|
|
|
|
if (!group) {
|
|
acc.push({
|
|
category: variable.category,
|
|
variables: [variable],
|
|
});
|
|
} else {
|
|
group.variables.push(variable);
|
|
}
|
|
|
|
return acc;
|
|
},
|
|
[] as Array<{ category: string; variables: Array<EnvVariableState> }>,
|
|
);
|
|
}
|