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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
26
src/components/connections/EnvironmentBadge.tsx
Normal file
26
src/components/connections/EnvironmentBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
13
src/lib/environment.ts
Normal 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;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export interface ConnectionConfig {
|
||||
database: string;
|
||||
ssl_mode?: string;
|
||||
color?: string;
|
||||
environment?: string;
|
||||
}
|
||||
|
||||
export interface QueryResult {
|
||||
|
||||
Reference in New Issue
Block a user