Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 11 additions & 3 deletions keep-ui/auth.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,19 @@ export class BackendRefusedError extends AuthError {
static type = "BackendRefusedError";
}

const authSessionTimeout = process.env.AUTH_SESSION_TIMEOUT
? Number.parseInt(process.env.AUTH_SESSION_TIMEOUT)
// Read env vars via bracket notation to prevent webpack DefinePlugin from
// inlining them as `undefined` at build time. This file is imported by
// middleware.ts (Edge Runtime) where DefinePlugin replaces direct
// `process.env.X` references with their build-time values.
function runtimeEnv(key: string): string | undefined {
return process.env[key];
}

const authSessionTimeout = runtimeEnv("AUTH_SESSION_TIMEOUT")
? Number.parseInt(runtimeEnv("AUTH_SESSION_TIMEOUT")!)
: 30 * 24 * 60 * 60; // Default to 30 days if not set
// Determine auth type with backward compatibility
const authTypeEnv = process.env.AUTH_TYPE;
const authTypeEnv = runtimeEnv("AUTH_TYPE");
export const authType =
authTypeEnv === MULTI_TENANT
? AuthType.AUTH0
Expand Down
2 changes: 2 additions & 0 deletions keep-ui/entities/alerts/model/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ export { DEFAULT_ROW_STYLE } from "./constants";
export { getTabsFromPreset } from "@/entities/alerts/lib/getTabsFromPreset";
export { useAlertTableTheme } from "@/entities/alerts/model/useAlertTableTheme";
export { useAlertRowStyle } from "@/entities/alerts/model/useAlertRowStyle";
export { useSeverityMapping, getMappedColor } from "@/entities/alerts/model/useSeverityMapping";
export type { SeverityMappingConfig } from "@/entities/alerts/model/useSeverityMapping";
export { useAlerts } from "./useAlerts";
46 changes: 46 additions & 0 deletions keep-ui/entities/alerts/model/useSeverityMapping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { useLocalStorage } from "@/utils/hooks/useLocalStorage";
import { AlertDto } from "./types";
import { getNestedValue } from "@/shared/lib/object-utils";

export interface SeverityMappingConfig {
enabled: boolean;
sourceField: string;
mappings: Record<string, string>; // value → hex color
}

const defaultConfig: SeverityMappingConfig = {
enabled: false,
sourceField: "",
mappings: {},
};

export function useSeverityMapping() {
const [severityMapping, setSeverityMapping] =
useLocalStorage<SeverityMappingConfig>("severity-mapping", defaultConfig);

return { severityMapping, setSeverityMapping };
}

/**
* Returns the custom color for an alert based on the mapping config,
* or null if no mapping applies.
*/
export function getMappedColor(
alert: AlertDto,
config: SeverityMappingConfig
): string | null {
if (!config.enabled || !config.sourceField) {
return null;
}

const value = getNestedValue(alert, config.sourceField);
if (value != null) {
const stringValue = String(value);
const color = config.mappings[stringValue];
if (color && color.startsWith("#")) {
return color;
}
}

return null;
}
2 changes: 2 additions & 0 deletions keep-ui/features/alerts/severity-mapping/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { SeverityMappingSelection } from "./ui/SeverityMappingSelection";
export { SeverityMappingFacet } from "./ui/SeverityMappingFacet";
140 changes: 140 additions & 0 deletions keep-ui/features/alerts/severity-mapping/ui/SeverityMappingFacet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { useState } from "react";
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
import { Button, Text, Title } from "@tremor/react";
import { SeverityMappingConfig } from "@/entities/alerts/model/useSeverityMapping";

interface SeverityMappingFacetProps {
config: SeverityMappingConfig;
onCelChange: (cel: string) => void;
}

export function SeverityMappingFacet({
config,
onCelChange,
}: SeverityMappingFacetProps) {
const [isOpen, setIsOpen] = useState(true);
const mappingEntries = Object.entries(config.mappings);
const [selected, setSelected] = useState<Record<string, boolean>>(() =>
Object.fromEntries(mappingEntries.map(([value]) => [value, true]))
);

const exclusivelySelected = (value: string) => {
const selectedValues = mappingEntries.filter(([v]) => selected[v]);
return selectedValues.length === 1 && selected[value];
};

const buildCel = (newSelected: Record<string, boolean>) => {
const unchecked = mappingEntries.filter(([value]) => !newSelected[value]);
if (unchecked.length === 0) {
return "";
}
// Filter OUT unchecked values
const conditions = unchecked.map(
([value]) => `${config.sourceField} != "${value}"`
);
return conditions.join(" && ");
};

const toggle = (value: string) => {
const newSelected = { ...selected, [value]: !selected[value] };
setSelected(newSelected);
onCelChange(buildCel(newSelected));
};

const selectOnly = (value: string) => {
const newSelected = Object.fromEntries(
mappingEntries.map(([v]) => [v, v === value])
);
setSelected(newSelected);
onCelChange(buildCel(newSelected));
};

const selectAll = () => {
const newSelected = Object.fromEntries(
mappingEntries.map(([v]) => [v, true])
);
setSelected(newSelected);
onCelChange("");
};

if (!config.enabled || mappingEntries.length === 0) {
return null;
}

const Icon = isOpen ? ChevronDownIcon : ChevronRightIcon;

return (
<div className="pb-2 border-b border-gray-200">
<div
className="flex items-center px-2 py-2 cursor-pointer hover:bg-gray-50"
onClick={() => setIsOpen(!isOpen)}
>
<div className="flex items-center space-x-2">
<Icon className="size-5 -m-0.5 text-gray-600" />
<Title className="text-sm capitalize">{config.sourceField}</Title>
</div>
</div>

{isOpen && (
<div>
{mappingEntries.map(([value, color]) => {
const isChecked = selected[value];
const isExclusive = exclusivelySelected(value);

return (
<div
key={value}
className="flex items-center px-2 py-1 h-7 hover:bg-gray-100 rounded-sm cursor-pointer group"
onClick={() => toggle(value)}
>
<div className="flex items-center min-w-[24px]">
<input
type="checkbox"
readOnly
checked={isChecked}
style={{ accentColor: "#eb6221" }}
className="h-4 w-4 rounded border-gray-300 cursor-pointer"
/>
</div>

<div
className="flex-1 flex items-center min-w-0 gap-1"
title={value}
>
<div className="flex items-center">
<div
className="w-1 h-4 rounded-lg"
style={{ backgroundColor: color }}
/>
</div>
<Text className="truncate flex-1" title={value}>
{value}
</Text>
</div>

<div className="flex-shrink-0 w-8 text-right flex justify-end">
<Button
size="xs"
variant="light"
color="orange"
onClick={(e) => {
e.stopPropagation();
if (isExclusive) {
selectAll();
} else {
selectOnly(value);
}
}}
className="hidden group-hover:block !p-0 !text-xs"
>
{isExclusive ? "All" : "Only"}
</Button>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { useState } from "react";
import { Button, TextInput } from "@tremor/react";
import {
SeverityMappingConfig,
useSeverityMapping,
} from "@/entities/alerts/model/useSeverityMapping";
import { TrashIcon } from "@heroicons/react/24/outline";

interface MappingEntry {
value: string;
color: string;
}

const DEFAULT_COLOR = "#3b82f6";

export function SeverityMappingSelection({
onClose,
}: {
onClose?: () => void;
}) {
const { severityMapping, setSeverityMapping } = useSeverityMapping();

const [enabled, setEnabled] = useState(severityMapping.enabled);
const [sourceField, setSourceField] = useState(severityMapping.sourceField);
const [entries, setEntries] = useState<MappingEntry[]>(() => {
const existing = Object.entries(severityMapping.mappings);
return existing.length > 0
? existing.map(([value, color]) => ({ value, color }))
: [{ value: "", color: DEFAULT_COLOR }];
});

const addEntry = () => {
setEntries([...entries, { value: "", color: DEFAULT_COLOR }]);
};

const removeEntry = (index: number) => {
setEntries(entries.filter((_, i) => i !== index));
};

const updateEntryValue = (index: number, value: string) => {
const updated = [...entries];
updated[index] = { ...updated[index], value };
setEntries(updated);
};

const updateEntryColor = (index: number, color: string) => {
const updated = [...entries];
updated[index] = { ...updated[index], color };
setEntries(updated);
};

const handleApply = () => {
const mappings: Record<string, string> = {};
for (const entry of entries) {
if (entry.value.trim()) {
mappings[entry.value.trim()] = entry.color;
}
}

const config: SeverityMappingConfig = {
enabled,
sourceField: sourceField.trim(),
mappings,
};

setSeverityMapping(config);
onClose?.();
};

return (
<div className="flex flex-col h-full">
<div className="flex-1 overflow-hidden flex flex-col">
<span className="text-gray-400 text-sm mb-2">
Map alert field values to custom bar colors
</span>

<label className="flex items-center gap-2 mb-3 cursor-pointer">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
className="rounded border-gray-300"
/>
<span className="text-sm">Enable custom severity mapping</span>
</label>

{enabled && (
<>
<div className="mb-3">
<label className="text-sm text-gray-500 mb-1 block">
Source field
</label>
<TextInput
placeholder="e.g. priority"
value={sourceField}
onValueChange={setSourceField}
/>
</div>

<div className="flex-1 overflow-y-auto">
<label className="text-sm text-gray-500 mb-1 block">
Value → Color
</label>
<div className="space-y-2">
{entries.map((entry, index) => (
<div key={index} className="flex items-center gap-2">
<TextInput
className="flex-1"
placeholder="e.g. P1"
value={entry.value}
onValueChange={(v) => updateEntryValue(index, v)}
/>
<input
type="color"
value={entry.color}
onChange={(e) => updateEntryColor(index, e.target.value)}
className="w-8 h-8 rounded cursor-pointer border border-gray-300 p-0.5"
/>
<button
onClick={() => removeEntry(index)}
className="p-1 text-gray-400 hover:text-red-500"
aria-label="Remove mapping"
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
))}
</div>

<Button
variant="light"
color="orange"
size="xs"
className="mt-2"
onClick={addEntry}
>
+ Add mapping
</Button>
</div>
</>
)}
</div>

<Button className="mt-4" color="orange" onClick={handleApply}>
Apply
</Button>
</div>
);
}
Loading
Loading