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 database: String,
pub ssl_mode: Option<String>, pub ssl_mode: Option<String>,
pub color: Option<String>, pub color: Option<String>,
pub environment: Option<String>,
} }
impl ConnectionConfig { impl ConnectionConfig {

View File

@@ -18,6 +18,7 @@ import {
import { useSaveConnection, useTestConnection } from "@/hooks/use-connections"; import { useSaveConnection, useTestConnection } from "@/hooks/use-connections";
import { toast } from "sonner"; import { toast } from "sonner";
import type { ConnectionConfig } from "@/types"; import type { ConnectionConfig } from "@/types";
import { ENVIRONMENTS } from "@/lib/environment";
import { Loader2, X } from "lucide-react"; import { Loader2, X } from "lucide-react";
const CONNECTION_COLORS = [ const CONNECTION_COLORS = [
@@ -47,6 +48,7 @@ const emptyConfig: ConnectionConfig = {
database: "postgres", database: "postgres",
ssl_mode: "prefer", ssl_mode: "prefer",
color: undefined, color: undefined,
environment: undefined,
}; };
export function ConnectionDialog({ open, onOpenChange, connection }: Props) { export function ConnectionDialog({ open, onOpenChange, connection }: Props) {
@@ -184,6 +186,38 @@ export function ConnectionDialog({ open, onOpenChange, connection }: Props) {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </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"> <div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground"> <label className="text-right text-sm text-muted-foreground">
Color Color

View File

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

View File

@@ -7,6 +7,7 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useConnections } from "@/hooks/use-connections"; import { useConnections } from "@/hooks/use-connections";
import { useAppStore } from "@/stores/app-store"; import { useAppStore } from "@/stores/app-store";
import { EnvironmentBadge } from "@/components/connections/EnvironmentBadge";
export function ConnectionSelector() { export function ConnectionSelector() {
const { data: connections } = useConnections(); const { data: connections } = useConnections();
@@ -37,6 +38,7 @@ export function ConnectionSelector() {
/> />
)} )}
<SelectValue placeholder="Select connection" /> <SelectValue placeholder="Select connection" />
<EnvironmentBadge environment={activeConn?.environment} size="sm" />
</div> </div>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -50,6 +52,7 @@ export function ConnectionSelector() {
/> />
)} )}
{conn.name} {conn.name}
<EnvironmentBadge environment={conn.environment} size="sm" />
</div> </div>
</SelectItem> </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 { useAppStore } from "@/stores/app-store";
import { useConnections } from "@/hooks/use-connections"; import { useConnections } from "@/hooks/use-connections";
import { Circle } from "lucide-react"; import { Circle } from "lucide-react";
import { EnvironmentBadge } from "@/components/connections/EnvironmentBadge";
interface Props { interface Props {
rowCount?: number | null; rowCount?: number | null;
@@ -31,6 +32,7 @@ export function StatusBar({ rowCount, executionTime }: Props) {
/> />
)} )}
{activeConn ? activeConn.name : "No connection"} {activeConn ? activeConn.name : "No connection"}
<EnvironmentBadge environment={activeConn?.environment} size="sm" />
</span> </span>
{isConnected && activeConnectionId && ( {isConnected && activeConnectionId && (
<span <span

View File

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