diff --git a/frontend/src/app/[lang]/dashboard/admin/pools/page.tsx b/frontend/src/app/[lang]/dashboard/admin/pools/page.tsx
index 421f50c..7f3ea3b 100644
--- a/frontend/src/app/[lang]/dashboard/admin/pools/page.tsx
+++ b/frontend/src/app/[lang]/dashboard/admin/pools/page.tsx
@@ -8,6 +8,7 @@ import { useT, useLocale } from '@/i18n/I18nProvider';
import type { Cluster, ClusterPool } from '@/types';
import { Scale, BarChart3, RotateCw, CheckCircle, XCircle, Pencil, Clock, X } from 'lucide-react';
import { useConfirm } from '@/components/confirm-modal';
+import { Select } from '@/components/ui/select';
export default function AdminPoolsPage() {
const t = useT();
@@ -150,18 +151,20 @@ export default function AdminPoolsPage() {
createUser.mutate(form)}
@@ -241,16 +245,18 @@ export default function AdminUsersPage() {
{user.email}
{isAdmin ? (
- changeRole.mutate({ id: user.id, role: e.target.value })}
- >
- {u.roles.user}
- {u.roles.admin}
- {u.roles.technical}
- {u.roles.sales}
-
+ onChange={(v) => changeRole.mutate({ id: user.id, role: v })}
+ options={[
+ { value: 'user', label: u.roles.user },
+ { value: 'admin', label: u.roles.admin },
+ { value: 'technical', label: u.roles.technical },
+ { value: 'sales', label: u.roles.sales },
+ ]}
+ />
) : (
{isAdmin ? (
-
changeRole.mutate({ id: user.id, role: e.target.value })}
- >
- {u.roles.user}
- {u.roles.admin}
- {u.roles.technical}
- {u.roles.sales}
-
+ onChange={(v) => changeRole.mutate({ id: user.id, role: v })}
+ options={[
+ { value: 'user', label: u.roles.user },
+ { value: 'admin', label: u.roles.admin },
+ { value: 'technical', label: u.roles.technical },
+ { value: 'sales', label: u.roles.sales },
+ ]}
+ />
) : (
{roleLabel(user.role)}
)}
diff --git a/frontend/src/app/[lang]/dashboard/apps/[id]/page.tsx b/frontend/src/app/[lang]/dashboard/apps/[id]/page.tsx
index 3fcdb73..2cc48df 100644
--- a/frontend/src/app/[lang]/dashboard/apps/[id]/page.tsx
+++ b/frontend/src/app/[lang]/dashboard/apps/[id]/page.tsx
@@ -19,6 +19,7 @@ import { useConfirm } from '@/components/confirm-modal';
import { useAuthStore } from '@/lib/store';
import { useDeployProgressActions } from '@/lib/use-deploy-progress-actions';
import { useDeployProgressStore } from '@/lib/deploy-progress-store';
+import { Select } from '@/components/ui/select';
/** Matches backend multipart limit for POST /applications/:id/upload */
const MAX_SOURCE_ARCHIVE_BYTES = 10 * 1024 ** 3;
@@ -2243,16 +2244,19 @@ export default function AppDetailPage() {
{ad.workload}
- setScaleWorkload(e.target.value as typeof scaleWorkload)}
- className="input-field text-sm max-w-md"
- >
- {ad.productTypeApp}
- {app?.databaseType !== 'none' && {ad.database} }
- {app?.enableRedis && {ad.workload} }
- {app?.enableRabbitmq && {ad.workload} }
-
+ onChange={(v) => setScaleWorkload(v as typeof scaleWorkload)}
+ options={[
+ { value: 'app', label: ad.productTypeApp },
+ ...(app?.databaseType !== 'none' ? [{ value: 'database', label: ad.database }] : []),
+ ...(app?.enableRedis ? [{ value: 'redis', label: ad.workload }] : []),
+ ...(app?.enableRabbitmq ? [{ value: 'rabbitmq', label: ad.workload }] : []),
+ ]}
+ />
diff --git a/frontend/src/app/[lang]/dashboard/deploy/page.tsx b/frontend/src/app/[lang]/dashboard/deploy/page.tsx
index 6ce78a5..4442d77 100644
--- a/frontend/src/app/[lang]/dashboard/deploy/page.tsx
+++ b/frontend/src/app/[lang]/dashboard/deploy/page.tsx
@@ -5,6 +5,7 @@ import { useMutation, useQuery } from '@tanstack/react-query';
import api from '@/lib/api';
import { useT, useLocale } from '@/i18n/I18nProvider';
import { useLocalizedRouter } from '@/i18n/navigation';
+import { Select } from '@/components/ui/select';
import { parseDotenv } from '@/lib/parseDotenv';
import { useAuthStore } from '@/lib/store';
import { useDeployProgressStore } from '@/lib/deploy-progress-store';
@@ -100,60 +101,68 @@ function WorkloadResourceFields({
{dw.cpuRequest}
- onChange({ cpuRequest: e.target.value })}
- >
- 50m (0.05 core)
- 100m (0.1 core)
- 250m (0.25 core)
- 500m (0.5 core)
-
+ onChange={(v) => onChange({ cpuRequest: v })}
+ options={[
+ { value: '50m', label: '50m (0.05 core)' },
+ { value: '100m', label: '100m (0.1 core)' },
+ { value: '250m', label: '250m (0.25 core)' },
+ { value: '500m', label: '500m (0.5 core)' },
+ ]}
+ />
{dw.cpuLimit}
- onChange({ cpuLimit: e.target.value })}
- >
- 200m (0.2 core)
- 250m (0.25 core)
- 500m (0.5 core)
- 1 core
- 2 cores
-
+ onChange={(v) => onChange({ cpuLimit: v })}
+ options={[
+ { value: '200m', label: '200m (0.2 core)' },
+ { value: '250m', label: '250m (0.25 core)' },
+ { value: '500m', label: '500m (0.5 core)' },
+ { value: '1', label: '1 core' },
+ { value: '2', label: '2 cores' },
+ ]}
+ />
{dw.memoryRequest}
- onChange({ memoryRequest: e.target.value })}
- >
- 64 Mi
- 128 Mi
- 256 Mi
- 512 Mi
-
+ onChange={(v) => onChange({ memoryRequest: v })}
+ options={[
+ { value: '64Mi', label: '64 Mi' },
+ { value: '128Mi', label: '128 Mi' },
+ { value: '256Mi', label: '256 Mi' },
+ { value: '512Mi', label: '512 Mi' },
+ ]}
+ />
{dw.memoryLimit}
- onChange({ memoryLimit: e.target.value })}
- >
- 256 Mi
- 512 Mi
- 1 Gi
- 2 Gi
-
+ onChange={(v) => onChange({ memoryLimit: v })}
+ options={[
+ { value: '256Mi', label: '256 Mi' },
+ { value: '512Mi', label: '512 Mi' },
+ { value: '1Gi', label: '1 Gi' },
+ { value: '2Gi', label: '2 Gi' },
+ ]}
+ />
@@ -1353,119 +1362,89 @@ export default function DeployPage() {
{form.runtime === 'nodejs' && (
{dw.nodeVersion}
- setForm({ ...form, runtimeVersion: e.target.value })}
- >
- Node.js 22 (LTS)
- Node.js 20 (LTS)
- Node.js 18
- Node.js 16
-
+ onChange={(v) => setForm({ ...form, runtimeVersion: v })}
+ options={[{ value: '22', label: 'Node.js 22 (LTS)' },{ value: '20', label: 'Node.js 20 (LTS)' },{ value: '18', label: 'Node.js 18' },{ value: '16', label: 'Node.js 16' }]}
+ />
)}
{form.runtime === 'laravel' && (
{dw.phpVersion}
- setForm({ ...form, phpVersion: e.target.value })}
- >
- PHP 8.4
- PHP 8.3 (Recommended)
- PHP 8.2
- PHP 8.1
-
+ onChange={(v) => setForm({ ...form, phpVersion: v })}
+ options={[{ value: '8.4', label: 'PHP 8.4' },{ value: '8.3', label: 'PHP 8.3 (Recommended)' },{ value: '8.2', label: 'PHP 8.2' },{ value: '8.1', label: 'PHP 8.1' }]}
+ />
)}
{form.runtime === 'wordpress' && (
<>
{dw.wordpressVersion}
- setForm({ ...form, runtimeVersion: e.target.value })}
- >
- WordPress 6.7 (Latest)
- WordPress 6.6
- WordPress 6.5
- WordPress 6.4
-
+ setForm({ ...form, runtimeVersion: v })}
+ options={[{ value: '6.7', label: 'WordPress 6.7 (Latest)' },{ value: '6.6', label: 'WordPress 6.6' },{ value: '6.5', label: 'WordPress 6.5' },{ value: '6.4', label: 'WordPress 6.4' }]}
+ />
{dw.phpVersion}
- setForm({ ...form, phpVersion: e.target.value })}
- >
- PHP 8.3 (Recommended)
- PHP 8.2
- PHP 8.1
-
+ setForm({ ...form, phpVersion: v })}
+ options={[{ value: '8.3', label: 'PHP 8.3 (Recommended)' },{ value: '8.2', label: 'PHP 8.2' },{ value: '8.1', label: 'PHP 8.1' }]}
+ />
>
)}
{form.runtime === 'go' && (
{dw.goVersion}
- setForm({ ...form, runtimeVersion: e.target.value })}
- >
- Go 1.23 (Latest)
- Go 1.22 (Recommended)
- Go 1.21
-
+ onChange={(v) => setForm({ ...form, runtimeVersion: v })}
+ options={[{ value: '1.23', label: 'Go 1.23 (Latest)' },{ value: '1.22', label: 'Go 1.22 (Recommended)' },{ value: '1.21', label: 'Go 1.21' }]}
+ />
)}
{(form.runtime === 'python' || form.runtime === 'django') && (
{dw.pythonVersion}
- setForm({ ...form, runtimeVersion: e.target.value })}
- >
- Python 3.13 (Latest)
- Python 3.12 (Recommended)
- Python 3.11
- Python 3.10
-
+ onChange={(v) => setForm({ ...form, runtimeVersion: v })}
+ options={[{ value: '3.13', label: 'Python 3.13 (Latest)' },{ value: '3.12', label: 'Python 3.12 (Recommended)' },{ value: '3.11', label: 'Python 3.11' },{ value: '3.10', label: 'Python 3.10' }]}
+ />
)}
{form.runtime === 'php' && (
{dw.phpVersion}
- setForm({ ...form, phpVersion: e.target.value })}
- >
- PHP 8.4 (Latest)
- PHP 8.3 (Recommended)
- PHP 8.2
- PHP 8.1
-
+ onChange={(v) => setForm({ ...form, phpVersion: v })}
+ options={[{ value: '8.4', label: 'PHP 8.4 (Latest)' },{ value: '8.3', label: 'PHP 8.3 (Recommended)' },{ value: '8.2', label: 'PHP 8.2' },{ value: '8.1', label: 'PHP 8.1' }]}
+ />
)}
{form.runtime === 'dotnet' && (
.NET Version
- setForm({ ...form, runtimeVersion: e.target.value })}
- >
- .NET 9.0 (Latest)
- .NET 8.0 LTS (Recommended)
- .NET 7.0
- .NET 6.0 LTS
-
+ onChange={(v) => setForm({ ...form, runtimeVersion: v })}
+ options={[{ value: '9.0', label: '.NET 9.0 (Latest)' },{ value: '8.0', label: '.NET 8.0 LTS (Recommended)' },{ value: '7.0', label: '.NET 7.0' },{ value: '6.0', label: '.NET 6.0 LTS' }]}
+ />
)}
@@ -1518,40 +1497,40 @@ export default function DeployPage() {
{form.databaseType === 'postgresql' ? 'PostgreSQL' : form.databaseType === 'mysql' ? 'MySQL' : form.databaseType === 'mariadb' ? 'MariaDB' : 'MongoDB'} Version
-
setForm({ ...form, dbVersion: e.target.value })}
- >
- {form.databaseType === 'postgresql' ? (
- <>
- PostgreSQL 17 (Latest)
- PostgreSQL 16 (LTS)
- PostgreSQL 15
- PostgreSQL 14
- >
- ) : form.databaseType === 'mysql' ? (
- <>
- MySQL 9.0 (Latest)
- MySQL 8.4 (LTS)
- MySQL 8.0
- MySQL 5.7
- >
- ) : form.databaseType === 'mariadb' ? (
- <>
- MariaDB 11.4 (LTS)
- MariaDB 11.3
- MariaDB 10.11 (LTS)
- MariaDB 10.6
- >
- ) : (
- <>
- MongoDB 7.0 (Latest)
- MongoDB 6.0
- MongoDB 5.0
- >
- )}
-
+ onChange={(v) => setForm({ ...form, dbVersion: v })}
+ options={
+ form.databaseType === 'postgresql'
+ ? [
+ { value: '17', label: 'PostgreSQL 17 (Latest)' },
+ { value: '16', label: 'PostgreSQL 16 (LTS)' },
+ { value: '15', label: 'PostgreSQL 15' },
+ { value: '14', label: 'PostgreSQL 14' },
+ ]
+ : form.databaseType === 'mysql'
+ ? [
+ { value: '9.0', label: 'MySQL 9.0 (Latest)' },
+ { value: '8.4', label: 'MySQL 8.4 (LTS)' },
+ { value: '8.0', label: 'MySQL 8.0' },
+ { value: '5.7', label: 'MySQL 5.7' },
+ ]
+ : form.databaseType === 'mariadb'
+ ? [
+ { value: '11.4', label: 'MariaDB 11.4 (LTS)' },
+ { value: '11.3', label: 'MariaDB 11.3' },
+ { value: '10.11', label: 'MariaDB 10.11 (LTS)' },
+ { value: '10.6', label: 'MariaDB 10.6' },
+ ]
+ : [
+ { value: '7.0', label: 'MongoDB 7.0 (Latest)' },
+ { value: '6.0', label: 'MongoDB 6.0' },
+ { value: '5.0', label: 'MongoDB 5.0' },
+ ]
+ }
+ />
)}
@@ -1793,17 +1772,18 @@ export default function DeployPage() {
{dw.versionColon}
- setForm({ ...form, redisVersion: e.target.value })}
- onClick={(e) => e.stopPropagation()}
- >
- 7.2 (Latest)
- 7.0
- 6.2 (LTS)
- 6.0
-
+ onChange={(v) => setForm({ ...form, redisVersion: v })}
+ options={[
+ { value: '7.2', label: '7.2 (Latest)' },
+ { value: '7.0', label: '7.0' },
+ { value: '6.2', label: '6.2 (LTS)' },
+ { value: '6.0', label: '6.0' },
+ ]}
+ />
REDIS_HOST, REDIS_PASSWORD, REDIS_URL
@@ -1851,17 +1831,18 @@ export default function DeployPage() {
{dw.versionColon}
- setForm({ ...form, rabbitmqVersion: e.target.value })}
- onClick={(e) => e.stopPropagation()}
- >
- 3.13 (Latest)
- 3.12 (LTS)
- 3.11
- 3.10
-
+ onChange={(v) => setForm({ ...form, rabbitmqVersion: v })}
+ options={[
+ { value: '3.13', label: '3.13 (Latest)' },
+ { value: '3.12', label: '3.12 (LTS)' },
+ { value: '3.11', label: '3.11' },
+ { value: '3.10', label: '3.10' },
+ ]}
+ />
RABBITMQ_HOST, RABBITMQ_USER, AMQP_URL
@@ -1896,17 +1877,18 @@ export default function DeployPage() {
{dw.versionColon}
- setForm({ ...form, elasticsearchVersion: e.target.value })}
- onClick={(e) => e.stopPropagation()}
- >
- 8.12 (Latest)
- 8.11
- 7.17 (LTS)
- 7.10
-
+ onChange={(v) => setForm({ ...form, elasticsearchVersion: v })}
+ options={[
+ { value: '8.12', label: '8.12 (Latest)' },
+ { value: '8.11', label: '8.11' },
+ { value: '7.17', label: '7.17 (LTS)' },
+ { value: '7.10', label: '7.10' },
+ ]}
+ />
{dw.logsViaFluentBit}
@@ -2305,39 +2287,63 @@ export default function DeployPage() {
{dw.cpuRequest}
- setForm({ ...form, cpuRequest: e.target.value })}>
- 50m (0.05 core)
- 100m (0.1 core)
- 250m (0.25 core)
- 500m (0.5 core)
-
+ setForm({ ...form, cpuRequest: v })}
+ options={[
+ { value: '50m', label: '50m (0.05 core)' },
+ { value: '100m', label: '100m (0.1 core)' },
+ { value: '250m', label: '250m (0.25 core)' },
+ { value: '500m', label: '500m (0.5 core)' },
+ ]}
+ />
{dw.cpuLimit}
- setForm({ ...form, cpuLimit: e.target.value })}>
- 250m (0.25 core)
- 500m (0.5 core)
- 1 core
- 2 cores
-
+ setForm({ ...form, cpuLimit: v })}
+ options={[
+ { value: '250m', label: '250m (0.25 core)' },
+ { value: '500m', label: '500m (0.5 core)' },
+ { value: '1', label: '1 core' },
+ { value: '2', label: '2 cores' },
+ ]}
+ />
{dw.memoryRequest}
- setForm({ ...form, memoryRequest: e.target.value })}>
- 64 Mi
- 128 Mi
- 256 Mi
- 512 Mi
-
+ setForm({ ...form, memoryRequest: v })}
+ options={[
+ { value: '64Mi', label: '64 Mi' },
+ { value: '128Mi', label: '128 Mi' },
+ { value: '256Mi', label: '256 Mi' },
+ { value: '512Mi', label: '512 Mi' },
+ ]}
+ />
{dw.memoryLimit}
- setForm({ ...form, memoryLimit: e.target.value })}>
- 256 Mi
- 512 Mi
- 1 Gi
- 2 Gi
-
+ setForm({ ...form, memoryLimit: v })}
+ options={[
+ { value: '256Mi', label: '256 Mi' },
+ { value: '512Mi', label: '512 Mi' },
+ { value: '1Gi', label: '1 Gi' },
+ { value: '2Gi', label: '2 Gi' },
+ ]}
+ />
diff --git a/frontend/src/app/[lang]/dashboard/logs/page.tsx b/frontend/src/app/[lang]/dashboard/logs/page.tsx
index 2df0841..1c1fe20 100644
--- a/frontend/src/app/[lang]/dashboard/logs/page.tsx
+++ b/frontend/src/app/[lang]/dashboard/logs/page.tsx
@@ -6,6 +6,7 @@ import { useSearchParams } from 'next/navigation';
import api from '@/lib/api';
import { useT, useLocale } from '@/i18n/I18nProvider';
import type { Application, LogEntry, LogSearchResult, LogStatsResult } from '@/types';
+import { Select } from '@/components/ui/select';
import {
Loader2,
RefreshCw,
@@ -223,85 +224,69 @@ function LogsPageContent() {
{lg.resource}
- {
- setAppId(e.target.value);
+ onChange={(v) => {
+ setAppId(v);
setPage(1);
}}
- className="input w-full text-sm"
- >
- {lg.allResources}
- {applications.length > 0 && (
-
- {applications.map((app) => (
-
- {app.name}
-
- ))}
-
- )}
- {managedServices.length > 0 && (
-
- {managedServices.map((svc) => (
-
- {svc.name}
-
- ))}
-
- )}
-
+ options={[
+ { value: '', label: lg.allResources },
+ ...(applications.length > 0
+ ? [
+ { value: '__apps__', label: lg.applicationsGroup, disabled: true },
+ ...applications.map((app) => ({ value: app.id, label: app.name })),
+ ]
+ : []),
+ ...(managedServices.length > 0
+ ? [
+ { value: '__svcs__', label: lg.servicesGroup, disabled: true },
+ ...managedServices.map((svc) => ({ value: svc.id, label: svc.name })),
+ ]
+ : []),
+ ]}
+ />
{lg.source}
- {
- setWorkload(e.target.value);
+ onChange={(v) => {
+ setWorkload(v);
setPage(1);
}}
- className="input w-full text-sm"
- >
- {workloadOptions.map((w) => (
-
- {w.label}
-
- ))}
-
+ options={workloadOptions.map((w) => ({ value: w.value, label: w.label }))}
+ />
{lg.level}
- {
- setLevel(e.target.value);
+ onChange={(v) => {
+ setLevel(v);
setPage(1);
}}
- className="input w-full text-sm"
- >
- {LOG_LEVELS.map((l) => (
-
- {l.label}
-
- ))}
-
+ options={LOG_LEVELS.map((l) => ({ value: l.value, label: l.label }))}
+ />
{lg.timeRange}
- {
- setTimeRange(e.target.value);
+ onChange={(v) => {
+ setTimeRange(v);
setPage(1);
}}
- className="input w-full text-sm"
- >
- {TIME_RANGES.map((tr) => (
-
- {tr.label}
-
- ))}
-
+ options={TIME_RANGES.map((tr) => ({ value: tr.value, label: tr.label }))}
+ />
{lg.search}
diff --git a/frontend/src/app/[lang]/dashboard/services/new/page.tsx b/frontend/src/app/[lang]/dashboard/services/new/page.tsx
index c72d1a5..e2e59d7 100644
--- a/frontend/src/app/[lang]/dashboard/services/new/page.tsx
+++ b/frontend/src/app/[lang]/dashboard/services/new/page.tsx
@@ -7,6 +7,7 @@ import { toast } from 'react-toastify';
import { Link } from '@/i18n/Link';
import { useT } from '@/i18n/I18nProvider';
import { useLocalizedRouter } from '@/i18n/navigation';
+import { Select } from '@/components/ui/select';
import type {
Application,
CreateApplicationDto,
@@ -416,17 +417,14 @@ export default function NewManagedServicePage() {
{s.redisVersion}
- setForm({ ...form, redisVersion: e.target.value })}
- >
- {['7.2', '7.0', '6.2', '6.0'].map((v) => (
-
- {v}
-
- ))}
-
+ onChange={(v) => setForm({ ...form, redisVersion: v })}
+ options={['7.2', '7.0', '6.2', '6.0'].map((v) => ({ value: v, label: v }))}
+ />
{form.optionalServiceResources?.redis && (
{s.rabbitmqVersion}
- setForm({ ...form, rabbitmqVersion: e.target.value })}
- >
- {['3.13', '3.12', '3.11', '3.10'].map((v) => (
-
- {v}
-
- ))}
-
+ onChange={(v) => setForm({ ...form, rabbitmqVersion: v })}
+ options={['3.13', '3.12', '3.11', '3.10'].map((v) => ({ value: v, label: v }))}
+ />
{form.optionalServiceResources?.rabbitmq && (
= {
open: 'bg-yellow-100 text-yellow-700',
@@ -103,27 +104,27 @@ export default function TicketsPage() {
{tk.departmentField}
- setForm({ ...form, department: e.target.value as TicketDepartment })}
- >
- {availableDepartments.map((dept) => (
- {dept.label}
- ))}
-
+ onChange={(v) => setForm({ ...form, department: v as TicketDepartment })}
+ options={availableDepartments.map((dept) => ({ value: dept.value, label: dept.label }))}
+ />
{tk.priorityField}
- setForm({ ...form, priority: e.target.value as TicketPriority })}
- >
- {tk.priority.low}
- {tk.priority.medium}
- {tk.priority.high}
-
+ setForm({ ...form, priority: v as TicketPriority })}
+ options={[
+ { value: 'low', label: tk.priority.low },
+ { value: 'medium', label: tk.priority.medium },
+ { value: 'high', label: tk.priority.high },
+ ]}
+ />
diff --git a/frontend/src/components/database-workload-resources.tsx b/frontend/src/components/database-workload-resources.tsx
index 2437039..03a6397 100644
--- a/frontend/src/components/database-workload-resources.tsx
+++ b/frontend/src/components/database-workload-resources.tsx
@@ -2,6 +2,7 @@
import { Database } from 'lucide-react';
import { useT } from '@/i18n/I18nProvider';
+import { Select } from '@/components/ui/select';
export interface DatabaseWorkloadValues {
cpuRequest: string;
@@ -27,55 +28,63 @@ export function DatabaseWorkloadResources({
{r.cpuRequest}
- onChange({ cpuRequest: e.target.value })}
- >
- 50m
- 100m
- 250m
- 500m
-
+ onChange={(v) => onChange({ cpuRequest: v })}
+ options={[
+ { value: '50m', label: '50m' },
+ { value: '100m', label: '100m' },
+ { value: '250m', label: '250m' },
+ { value: '500m', label: '500m' },
+ ]}
+ />
{r.cpuLimit}
- onChange({ cpuLimit: e.target.value })}
- >
- 250m
- 500m
- {r.oneCore}
- {r.twoCores}
-
+ onChange={(v) => onChange({ cpuLimit: v })}
+ options={[
+ { value: '250m', label: '250m' },
+ { value: '500m', label: '500m' },
+ { value: '1', label: r.oneCore },
+ { value: '2', label: r.twoCores },
+ ]}
+ />
{r.memoryRequest}
- onChange({ memoryRequest: e.target.value })}
- >
- 64 Mi
- 128 Mi
- 256 Mi
- 512 Mi
-
+ onChange={(v) => onChange({ memoryRequest: v })}
+ options={[
+ { value: '64Mi', label: '64 Mi' },
+ { value: '128Mi', label: '128 Mi' },
+ { value: '256Mi', label: '256 Mi' },
+ { value: '512Mi', label: '512 Mi' },
+ ]}
+ />
{r.memoryLimit}
- onChange({ memoryLimit: e.target.value })}
- >
- 256 Mi
- 512 Mi
- 1 Gi
- 2 Gi
-
+ onChange={(v) => onChange({ memoryLimit: v })}
+ options={[
+ { value: '256Mi', label: '256 Mi' },
+ { value: '512Mi', label: '512 Mi' },
+ { value: '1Gi', label: '1 Gi' },
+ { value: '2Gi', label: '2 Gi' },
+ ]}
+ />
diff --git a/frontend/src/components/managed-database-config.tsx b/frontend/src/components/managed-database-config.tsx
index c7e9d67..22f69b7 100644
--- a/frontend/src/components/managed-database-config.tsx
+++ b/frontend/src/components/managed-database-config.tsx
@@ -4,6 +4,7 @@ import { useRef, useState } from 'react';
import { Database, Eye, EyeOff, RefreshCw, AlertCircle } from 'lucide-react';
import { toast } from 'react-toastify';
import { useT } from '@/i18n/I18nProvider';
+import { Select } from '@/components/ui/select';
import { ONE_GIB, minGiToFitFileBytes } from '@/lib/storage-utils';
export type DatabaseEngine = 'postgresql' | 'mysql' | 'mariadb' | 'mongodb';
@@ -101,17 +102,17 @@ export function ManagedDatabaseConfig({
{c.version}
- onChange({ dbVersion: e.target.value })}
- >
- {versionOptions(form.databaseType).map((v) => (
-
- {versionLabel(form.databaseType, v)}
-
- ))}
-
+ onChange={(v) => onChange({ dbVersion: v })}
+ options={versionOptions(form.databaseType).map((v) => ({
+ value: v,
+ label: versionLabel(form.databaseType, v),
+ }))}
+ />
diff --git a/frontend/src/components/optional-service-resource-fields.tsx b/frontend/src/components/optional-service-resource-fields.tsx
index 0bbd8b8..4437cee 100644
--- a/frontend/src/components/optional-service-resource-fields.tsx
+++ b/frontend/src/components/optional-service-resource-fields.tsx
@@ -2,6 +2,7 @@
import { Server } from 'lucide-react';
import { useT } from '@/i18n/I18nProvider';
+import { Select } from '@/components/ui/select';
import type { OptionalServiceResourceConfig } from '@/types';
export function OptionalServiceResourceFields({
@@ -34,31 +35,35 @@ export function OptionalServiceResourceFields({
{r.cpuLimit}
- onChange({ cpuLimit: e.target.value })}
- >
- 200m
- 500m
- {r.oneCore}
- {r.twoCores}
-
+ onChange={(v) => onChange({ cpuLimit: v })}
+ options={[
+ { value: '200m', label: '200m' },
+ { value: '500m', label: '500m' },
+ { value: '1', label: r.oneCore },
+ { value: '2', label: r.twoCores },
+ ]}
+ />
{r.memoryLimit}
- onChange({ memoryLimit: e.target.value })}
- >
- 256 Mi
- 512 Mi
- 1 Gi
- 2 Gi
-
+ onChange={(v) => onChange({ memoryLimit: v })}
+ options={[
+ { value: '256Mi', label: '256 Mi' },
+ { value: '512Mi', label: '512 Mi' },
+ { value: '1Gi', label: '1 Gi' },
+ { value: '2Gi', label: '2 Gi' },
+ ]}
+ />
{r.storageGb}
diff --git a/frontend/src/components/service-external-access-panel.tsx b/frontend/src/components/service-external-access-panel.tsx
index a4e38dc..c6ba7f9 100644
--- a/frontend/src/components/service-external-access-panel.tsx
+++ b/frontend/src/components/service-external-access-panel.tsx
@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { toast } from 'react-toastify';
import { useT } from '@/i18n/I18nProvider';
+import { Select } from '@/components/ui/select';
import type { Application, ServiceAccessGrant, ServiceAccessTarget } from '@/types';
import { ExternalLink, ShieldAlert, Copy, Check, Eye, EyeOff } from 'lucide-react';
@@ -129,17 +130,13 @@ export function ServiceExternalAccessPanel({
{ea.service}
- setAccessTarget(e.target.value as ServiceAccessTarget)}
- className="input-field w-full text-sm"
- >
- {accessTargetOptions.map((o) => (
-
- {o.label}
-
- ))}
-
+ onChange={(v) => setAccessTarget(v as ServiceAccessTarget)}
+ options={accessTargetOptions}
+ />
{ea.accessMode}
diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx
new file mode 100644
index 0000000..b7cc492
--- /dev/null
+++ b/frontend/src/components/ui/select.tsx
@@ -0,0 +1,127 @@
+'use client';
+
+import { useState, useRef, useEffect, useCallback } from 'react';
+import { ChevronDown, Check } from 'lucide-react';
+
+export type SelectOption = { value: string; label: string; disabled?: boolean };
+
+/**
+ * Lightweight custom dropdown. Unlike a native
, its option list is
+ * absolutely anchored right below the trigger, so it can never render detached
+ * at the top of the screen (a common mobile/emulator quirk). Flips above the
+ * trigger automatically when there isn't enough room below.
+ */
+export function Select({
+ value,
+ options,
+ onChange,
+ className = '',
+ ariaLabel,
+ placeholder,
+ size = 'sm',
+ disabled = false,
+}: {
+ value: string;
+ options: SelectOption[];
+ onChange: (value: string) => void;
+ className?: string;
+ ariaLabel?: string;
+ placeholder?: string;
+ /** sm: compact inline pickers; md: matches the .input-field form control. */
+ size?: 'sm' | 'md';
+ disabled?: boolean;
+}) {
+ const [open, setOpen] = useState(false);
+ const [flipUp, setFlipUp] = useState(false);
+ const rootRef = useRef(null);
+ const selected = options.find((o) => o.value === value);
+
+ const close = useCallback(() => setOpen(false), []);
+
+ useEffect(() => {
+ if (!open) return;
+ const onDocClick = (e: MouseEvent) => {
+ if (rootRef.current && !rootRef.current.contains(e.target as Node)) close();
+ };
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') close();
+ };
+ document.addEventListener('mousedown', onDocClick);
+ document.addEventListener('keydown', onKey);
+ return () => {
+ document.removeEventListener('mousedown', onDocClick);
+ document.removeEventListener('keydown', onKey);
+ };
+ }, [open, close]);
+
+ const toggle = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ if (disabled) return;
+ if (!open && rootRef.current) {
+ const rect = rootRef.current.getBoundingClientRect();
+ const spaceBelow = window.innerHeight - rect.bottom;
+ setFlipUp(spaceBelow < 240 && rect.top > spaceBelow);
+ }
+ setOpen((o) => !o);
+ };
+
+ return (
+
+
+
+ {selected?.label ?? placeholder ?? value}
+
+
+
+
+ {open && (
+
+ {options.map((opt) => {
+ const isSelected = opt.value === value;
+ return (
+ {
+ e.stopPropagation();
+ if (opt.disabled) return;
+ onChange(opt.value);
+ close();
+ }}
+ className={`flex items-center justify-between gap-2 px-3 py-2 text-sm transition-colors ${
+ opt.disabled
+ ? 'text-gray-300 cursor-not-allowed'
+ : isSelected
+ ? 'bg-primary-50/70 text-primary-700 font-medium cursor-pointer'
+ : 'text-gray-700 hover:bg-gray-50 cursor-pointer'
+ }`}
+ >
+ {opt.label}
+ {isSelected && }
+
+ );
+ })}
+
+ )}
+
+ );
+}