feat: add environment labels (local/dev/stage/prod) for connections

Visual badges help distinguish environments across the UI to prevent
running dangerous queries on production. Environment color takes
priority over custom connection color for the toolbar border.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 21:40:50 +03:00
parent ebc6a7e51a
commit 7f82ffe7f1
9 changed files with 210 additions and 77 deletions

View File

@@ -11,6 +11,7 @@ pub struct ConnectionConfig {
pub database: String,
pub ssl_mode: Option<String>,
pub color: Option<String>,
pub environment: Option<String>,
}
impl ConnectionConfig {

View File

@@ -18,6 +18,7 @@ import {
import { useSaveConnection, useTestConnection } from "@/hooks/use-connections";
import { toast } from "sonner";
import type { ConnectionConfig } from "@/types";
import { ENVIRONMENTS } from "@/lib/environment";
import { Loader2, X } from "lucide-react";
const CONNECTION_COLORS = [
@@ -47,6 +48,7 @@ const emptyConfig: ConnectionConfig = {
database: "postgres",
ssl_mode: "prefer",
color: undefined,
environment: undefined,
};
export function ConnectionDialog({ open, onOpenChange, connection }: Props) {
@@ -184,6 +186,38 @@ export function ConnectionDialog({ open, onOpenChange, connection }: Props) {
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
Environment
</label>
<Select
value={form.environment ?? "__none__"}
onValueChange={(v) =>
setForm((f) => ({
...f,
environment: v === "__none__" ? undefined : v,
}))
}
>
<SelectTrigger className="col-span-3">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None</SelectItem>
{ENVIRONMENTS.map((env) => (
<SelectItem key={env.value} value={env.value}>
<div className="flex items-center gap-2">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: env.color }}
/>
{env.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
Color

View File

@@ -23,6 +23,8 @@ import {
Loader2,
} from "lucide-react";
import type { ConnectionConfig } from "@/types";
import { EnvironmentBadge } from "@/components/connections/EnvironmentBadge";
import { ENVIRONMENTS } from "@/lib/environment";
interface Props {
open: boolean;
@@ -80,87 +82,127 @@ export function ConnectionList({ open, onOpenChange, onEdit, onNew }: Props) {
</SheetHeader>
<div className="overflow-y-auto overflow-x-hidden" style={{ height: "calc(100vh - 80px)" }}>
<div className="space-y-2 px-4 pb-4">
{connections?.map((conn) => {
const isConnected = connectedIds.has(conn.id);
const isActive = activeConnectionId === conn.id;
{(() => {
if (!connections || connections.length === 0) {
return (
<div className="py-8 text-center text-sm text-muted-foreground">
No saved connections.
<br />
Click "New" to add one.
</div>
);
}
return (
<div
key={conn.id}
className={`flex items-center gap-2 overflow-hidden rounded-md border p-3 ${
isActive ? "border-primary bg-accent" : ""
}`}
>
{conn.color ? (
<span
className="h-4 w-4 shrink-0 rounded-full"
style={{ backgroundColor: conn.color }}
/>
) : (
<Database className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">
{conn.name}
</div>
<div className="truncate text-xs text-muted-foreground">
{conn.host}:{conn.port}/{conn.database}
</div>
</div>
<div className="flex shrink-0 items-center gap-0.5">
{isConnected ? (
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => handleDisconnect(conn.id)}
disabled={disconnectMutation.isPending}
>
<Unplug className="h-3.5 w-3.5" />
</Button>
) : (
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => handleConnect(conn)}
disabled={connectMutation.isPending}
>
{connectMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
const envOrder = ["prod", "stage", "dev", "local"] as const;
const grouped = new Map<string, ConnectionConfig[]>();
for (const conn of connections) {
const key = conn.environment ?? "";
const list = grouped.get(key);
if (list) list.push(conn);
else grouped.set(key, [conn]);
}
const sortedKeys = [...grouped.keys()].sort((a, b) => {
const ai = a ? envOrder.indexOf(a as typeof envOrder[number]) : envOrder.length;
const bi = b ? envOrder.indexOf(b as typeof envOrder[number]) : envOrder.length;
return (ai === -1 ? envOrder.length : ai) - (bi === -1 ? envOrder.length : bi);
});
const showHeaders = sortedKeys.length > 1;
return sortedKeys.map((key) => {
const group = grouped.get(key)!;
const envDef = ENVIRONMENTS.find((e) => e.value === key);
return (
<div key={key || "__none__"}>
{showHeaders && (
<div className="mb-1 mt-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase text-muted-foreground first:mt-0">
{envDef ? (
<>
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: envDef.color }} />
{envDef.label}
</>
) : (
<Plug className="h-3.5 w-3.5" />
"Other"
)}
</Button>
</div>
)}
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => onEdit(conn)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-destructive"
onClick={() => handleDelete(conn.id)}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
{group.map((conn) => {
const isConnected = connectedIds.has(conn.id);
const isActive = activeConnectionId === conn.id;
return (
<div
key={conn.id}
className={`flex items-center gap-2 overflow-hidden rounded-md border p-3 mb-2 last:mb-0 ${
isActive ? "border-primary bg-accent" : ""
}`}
>
{conn.color ? (
<span
className="h-4 w-4 shrink-0 rounded-full"
style={{ backgroundColor: conn.color }}
/>
) : (
<Database className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 truncate text-sm font-medium">
{conn.name}
<EnvironmentBadge environment={conn.environment} size="sm" />
</div>
<div className="truncate text-xs text-muted-foreground">
{conn.host}:{conn.port}/{conn.database}
</div>
</div>
<div className="flex shrink-0 items-center gap-0.5">
{isConnected ? (
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => handleDisconnect(conn.id)}
disabled={disconnectMutation.isPending}
>
<Unplug className="h-3.5 w-3.5" />
</Button>
) : (
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => handleConnect(conn)}
disabled={connectMutation.isPending}
>
{connectMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Plug className="h-3.5 w-3.5" />
)}
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => onEdit(conn)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-destructive"
onClick={() => handleDelete(conn.id)}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
})}
</div>
</div>
);
})}
{(!connections || connections.length === 0) && (
<div className="py-8 text-center text-sm text-muted-foreground">
No saved connections.
<br />
Click "New" to add one.
</div>
)}
);
});
})()}
</div>
</div>
</SheetContent>

View File

@@ -7,6 +7,7 @@ import {
} from "@/components/ui/select";
import { useConnections } from "@/hooks/use-connections";
import { useAppStore } from "@/stores/app-store";
import { EnvironmentBadge } from "@/components/connections/EnvironmentBadge";
export function ConnectionSelector() {
const { data: connections } = useConnections();
@@ -37,6 +38,7 @@ export function ConnectionSelector() {
/>
)}
<SelectValue placeholder="Select connection" />
<EnvironmentBadge environment={activeConn?.environment} size="sm" />
</div>
</SelectTrigger>
<SelectContent>
@@ -50,6 +52,7 @@ export function ConnectionSelector() {
/>
)}
{conn.name}
<EnvironmentBadge environment={conn.environment} size="sm" />
</div>
</SelectItem>
))}

View File

@@ -0,0 +1,26 @@
import { getEnvironment } from "@/lib/environment";
import { cn } from "@/lib/utils";
interface Props {
environment?: string;
size?: "sm" | "md";
}
export function EnvironmentBadge({ environment, size = "sm" }: Props) {
const env = getEnvironment(environment);
if (!env) return null;
return (
<span
className={cn(
"inline-flex items-center rounded border font-semibold uppercase leading-none",
env.bgClass,
env.textClass,
env.borderClass,
size === "sm" ? "px-1.5 py-0.5 text-[10px]" : "px-2 py-0.5 text-xs"
)}
>
{env.label}
</span>
);
}

View File

@@ -1,6 +1,7 @@
import { useAppStore } from "@/stores/app-store";
import { useConnections } from "@/hooks/use-connections";
import { Circle } from "lucide-react";
import { EnvironmentBadge } from "@/components/connections/EnvironmentBadge";
interface Props {
rowCount?: number | null;
@@ -31,6 +32,7 @@ export function StatusBar({ rowCount, executionTime }: Props) {
/>
)}
{activeConn ? activeConn.name : "No connection"}
<EnvironmentBadge environment={activeConn?.environment} size="sm" />
</span>
{isConnected && activeConnectionId && (
<span

View File

@@ -9,6 +9,8 @@ import { useAppStore } from "@/stores/app-store";
import { useConnections } from "@/hooks/use-connections";
import { Database, Plus } from "lucide-react";
import type { ConnectionConfig, Tab } from "@/types";
import { getEnvironment } from "@/lib/environment";
import { EnvironmentBadge } from "@/components/connections/EnvironmentBadge";
export function Toolbar() {
const [listOpen, setListOpen] = useState(false);
@@ -16,7 +18,9 @@ export function Toolbar() {
const [editingConn, setEditingConn] = useState<ConnectionConfig | null>(null);
const { activeConnectionId, addTab } = useAppStore();
const { data: connections } = useConnections();
const activeColor = connections?.find((c) => c.id === activeConnectionId)?.color;
const activeConn = connections?.find((c) => c.id === activeConnectionId);
const activeEnv = getEnvironment(activeConn?.environment);
const activeColor = activeEnv?.color ?? activeConn?.color;
const handleNewQuery = () => {
if (!activeConnectionId) return;
@@ -54,6 +58,13 @@ export function Toolbar() {
<ReadOnlyToggle />
{activeConn?.environment && (
<>
<Separator orientation="vertical" className="h-5" />
<EnvironmentBadge environment={activeConn.environment} size="sm" />
</>
)}
<Separator orientation="vertical" className="h-5" />
<Button

13
src/lib/environment.ts Normal file
View File

@@ -0,0 +1,13 @@
export const ENVIRONMENTS = [
{ value: "local", label: "LOCAL", color: "#6b7280", bgClass: "bg-gray-500/20", textClass: "text-gray-400", borderClass: "border-gray-500/40" },
{ value: "dev", label: "DEV", color: "#3b82f6", bgClass: "bg-blue-500/20", textClass: "text-blue-400", borderClass: "border-blue-500/40" },
{ value: "stage", label: "STG", color: "#f59e0b", bgClass: "bg-amber-500/20", textClass: "text-amber-400", borderClass: "border-amber-500/40" },
{ value: "prod", label: "PROD", color: "#ef4444", bgClass: "bg-red-500/20", textClass: "text-red-400", borderClass: "border-red-500/40" },
] as const;
export type EnvironmentValue = (typeof ENVIRONMENTS)[number]["value"];
export function getEnvironment(value?: string) {
if (!value) return null;
return ENVIRONMENTS.find((e) => e.value === value) ?? null;
}

View File

@@ -8,6 +8,7 @@ export interface ConnectionConfig {
database: string;
ssl_mode?: string;
color?: string;
environment?: string;
}
export interface QueryResult {