Unify all dropdowns on the custom Select component.

Replace every native <select> across the dashboard, admin pages, and
shared components with the custom Select used by the optional-service
version pickers, for consistent styling and mobile-safe anchoring. Add
a disabled prop to Select to cover the former read-only native cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-12 17:37:04 +03:30
parent aa7ff3d26d
commit 97cd5e989a
16 changed files with 664 additions and 494 deletions
@@ -19,6 +19,7 @@ import {
import { TruncatedText } from '@/components/truncated-text'; import { TruncatedText } from '@/components/truncated-text';
import { useDebounce } from '@/hooks/useDebounce'; import { useDebounce } from '@/hooks/useDebounce';
import { useApplicationDelete } from '@/lib/use-application-delete'; import { useApplicationDelete } from '@/lib/use-application-delete';
import { Select } from '@/components/ui/select';
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
running: 'badge-green', running: 'badge-green',
@@ -605,23 +606,18 @@ export default function AdminAppsPage() {
</p> </p>
)} )}
</div> </div>
<select <Select
className="input-field" size="md"
ariaLabel={aa.selectHealthyCluster}
placeholder={aa.selectHealthyCluster}
value={targetClusterId} value={targetClusterId}
onChange={(e) => setTargetClusterId(e.target.value)} onChange={(v) => setTargetClusterId(v)}
> options={targetClusters.map((cluster) => ({
<option value="">{aa.selectHealthyCluster}</option> value: cluster.id,
{targetClusters label: `${cluster.name} · ${cluster.region || aa.na} · ${cluster.status}/${cluster.healthStatus || aa.unknown}`,
.map((cluster) => ( disabled: cluster.status !== 'active' || cluster.healthStatus !== 'healthy',
<option }))}
key={cluster.id} />
value={cluster.id}
disabled={cluster.status !== 'active' || cluster.healthStatus !== 'healthy'}
>
{cluster.name} · {cluster.region || aa.na} · {cluster.status}/{cluster.healthStatus || aa.unknown}
</option>
))}
</select>
<p className="text-xs text-gray-400 mt-1"> <p className="text-xs text-gray-400 mt-1">
{aa.currentExcludedNote} {aa.currentExcludedNote}
</p> </p>
@@ -15,6 +15,7 @@ import type {
LifecycleSettings, LifecycleSettings,
} from '@/types'; } from '@/types';
import { DollarSign, Edit2, Shield, Clock, Layers, Info, Box, Server, Globe } from 'lucide-react'; import { DollarSign, Edit2, Shield, Clock, Layers, Info, Box, Server, Globe } from 'lucide-react';
import { Select } from '@/components/ui/select';
const cycles: BillingCycle[] = ['hourly', 'monthly', 'yearly']; const cycles: BillingCycle[] = ['hourly', 'monthly', 'yearly'];
@@ -198,16 +199,19 @@ function DeployDefaultsFields({
{readOnly ? ( {readOnly ? (
<p className="text-sm font-mono mt-1">{profile.cpuRequest || '—'}</p> <p className="text-sm font-mono mt-1">{profile.cpuRequest || '—'}</p>
) : ( ) : (
<select <Select
className="input-field w-full text-sm mt-0.5" size="md"
className="mt-0.5"
ariaLabel={b.cpuRequest}
value={profile.cpuRequest || '50m'} value={profile.cpuRequest || '50m'}
onChange={(e) => onUpdate({ cpuRequest: e.target.value })} onChange={(v) => onUpdate({ cpuRequest: v })}
> options={[
<option value="50m">50m</option> { value: '50m', label: '50m' },
<option value="100m">100m</option> { value: '100m', label: '100m' },
<option value="250m">250m</option> { value: '250m', label: '250m' },
<option value="500m">500m</option> { value: '500m', label: '500m' },
</select> ]}
/>
)} )}
</div> </div>
<div> <div>
@@ -215,17 +219,20 @@ function DeployDefaultsFields({
{readOnly ? ( {readOnly ? (
<p className="text-sm font-mono mt-1">{profile.cpuLimit}</p> <p className="text-sm font-mono mt-1">{profile.cpuLimit}</p>
) : ( ) : (
<select <Select
className="input-field w-full text-sm mt-0.5" size="md"
className="mt-0.5"
ariaLabel={b.cpuLimit}
value={profile.cpuLimit} value={profile.cpuLimit}
onChange={(e) => onUpdate({ cpuLimit: e.target.value })} onChange={(v) => onUpdate({ cpuLimit: v })}
> options={[
<option value="200m">200m</option> { value: '200m', label: '200m' },
<option value="250m">250m</option> { value: '250m', label: '250m' },
<option value="500m">500m</option> { value: '500m', label: '500m' },
<option value="1">{b.oneCore}</option> { value: '1', label: b.oneCore },
<option value="2">{b.twoCores}</option> { value: '2', label: b.twoCores },
</select> ]}
/>
)} )}
</div> </div>
<div> <div>
@@ -233,16 +240,19 @@ function DeployDefaultsFields({
{readOnly ? ( {readOnly ? (
<p className="text-sm font-mono mt-1">{profile.memoryRequest || '—'}</p> <p className="text-sm font-mono mt-1">{profile.memoryRequest || '—'}</p>
) : ( ) : (
<select <Select
className="input-field w-full text-sm mt-0.5" size="md"
className="mt-0.5"
ariaLabel={b.memoryRequest}
value={profile.memoryRequest || '64Mi'} value={profile.memoryRequest || '64Mi'}
onChange={(e) => onUpdate({ memoryRequest: e.target.value })} onChange={(v) => onUpdate({ memoryRequest: v })}
> options={[
<option value="64Mi">64 Mi</option> { value: '64Mi', label: '64 Mi' },
<option value="128Mi">128 Mi</option> { value: '128Mi', label: '128 Mi' },
<option value="256Mi">256 Mi</option> { value: '256Mi', label: '256 Mi' },
<option value="512Mi">512 Mi</option> { value: '512Mi', label: '512 Mi' },
</select> ]}
/>
)} )}
</div> </div>
<div> <div>
@@ -250,16 +260,19 @@ function DeployDefaultsFields({
{readOnly ? ( {readOnly ? (
<p className="text-sm font-mono mt-1">{profile.memoryLimit}</p> <p className="text-sm font-mono mt-1">{profile.memoryLimit}</p>
) : ( ) : (
<select <Select
className="input-field w-full text-sm mt-0.5" size="md"
className="mt-0.5"
ariaLabel={b.memoryLimit}
value={profile.memoryLimit} value={profile.memoryLimit}
onChange={(e) => onUpdate({ memoryLimit: e.target.value })} onChange={(v) => onUpdate({ memoryLimit: v })}
> options={[
<option value="256Mi">256 Mi</option> { value: '256Mi', label: '256 Mi' },
<option value="512Mi">512 Mi</option> { value: '512Mi', label: '512 Mi' },
<option value="1Gi">1 Gi</option> { value: '1Gi', label: '1 Gi' },
<option value="2Gi">2 Gi</option> { value: '2Gi', label: '2 Gi' },
</select> ]}
/>
)} )}
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
@@ -8,6 +8,7 @@ import { useT, useLocale } from '@/i18n/I18nProvider';
import type { Cluster, ClusterResources } from '@/types'; import type { Cluster, ClusterResources } from '@/types';
import { Server, CheckCircle, XCircle, BarChart3, Clock, RotateCw, Plug, X, ScrollText } from 'lucide-react'; import { Server, CheckCircle, XCircle, BarChart3, Clock, RotateCw, Plug, X, ScrollText } from 'lucide-react';
import { useConfirm } from '@/components/confirm-modal'; import { useConfirm } from '@/components/confirm-modal';
import { Select } from '@/components/ui/select';
function ResourcePanel({ clusterId }: { clusterId: string }) { function ResourcePanel({ clusterId }: { clusterId: string }) {
const cl = useT().dashboard.clusters; const cl = useT().dashboard.clusters;
@@ -345,17 +346,16 @@ function ClusterToolsPanel({
<p className="text-sm text-gray-600 mt-1">{cl.toolsSubtitle}</p> <p className="text-sm text-gray-600 mt-1">{cl.toolsSubtitle}</p>
</div> </div>
{clusters.length > 1 && ( {clusters.length > 1 && (
<select <Select
className="input-field text-sm py-1.5 max-w-[220px]" className="max-w-[220px]"
ariaLabel={cl.toolsTitle}
value={clusterId || ''} value={clusterId || ''}
onChange={(e) => onSelectCluster(e.target.value)} onChange={(v) => onSelectCluster(v)}
> options={clusters.map((c) => ({
{clusters.map((c) => ( value: c.id,
<option key={c.id} value={c.id}> label: `${c.name}${c.isDefault ? cl.defaultSuffix : ''}`,
{c.name}{c.isDefault ? cl.defaultSuffix : ''} }))}
</option> />
))}
</select>
)} )}
</div> </div>
@@ -502,13 +502,19 @@ export default function AdminClustersPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">{cl.provider}</label> <label className="block text-sm font-medium text-gray-700 mb-1">{cl.provider}</label>
<select className="input-field" value={form.provider} onChange={(e) => setForm({ ...form, provider: e.target.value })}> <Select
<option value="">{cl.selectProvider}</option> size="md"
<option value="aws">AWS (EKS)</option> ariaLabel={cl.provider}
<option value="gcp">GCP (GKE)</option> placeholder={cl.selectProvider}
<option value="azure">Azure (AKS)</option> value={form.provider}
<option value="bare-metal">Bare Metal</option> onChange={(v) => setForm({ ...form, provider: v })}
</select> options={[
{ value: 'aws', label: 'AWS (EKS)' },
{ value: 'gcp', label: 'GCP (GKE)' },
{ value: 'azure', label: 'Azure (AKS)' },
{ value: 'bare-metal', label: 'Bare Metal' },
]}
/>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">{cl.weight}</label> <label className="block text-sm font-medium text-gray-700 mb-1">{cl.weight}</label>
@@ -7,6 +7,7 @@ import { toast } from 'react-toastify';
import api from '@/lib/api'; import api from '@/lib/api';
import { useT, useLocale } from '@/i18n/I18nProvider'; import { useT, useLocale } from '@/i18n/I18nProvider';
import type { Invoice, InvoiceStatus, PaymentMethod } from '@/types'; import type { Invoice, InvoiceStatus, PaymentMethod } from '@/types';
import { Select } from '@/components/ui/select';
const statusClasses: Record<InvoiceStatus, string> = { const statusClasses: Record<InvoiceStatus, string> = {
draft: 'badge-gray', draft: 'badge-gray',
@@ -108,18 +109,31 @@ export default function AdminInvoicesPage() {
className="input-field pl-9 rtl:pl-3 rtl:pr-9 w-full" className="input-field pl-9 rtl:pl-3 rtl:pr-9 w-full"
/> />
</div> </div>
<select value={status} onChange={(e) => setStatus(e.target.value as any)} className="input-field"> <Select
<option value="all">{inv.allStatuses}</option> size="md"
{(Object.keys(inv.status) as InvoiceStatus[]).map((value) => ( ariaLabel={inv.allStatuses}
<option key={value} value={value}>{statusLabel(value)}</option> value={status}
))} onChange={(v) => setStatus(v as any)}
</select> options={[
<select value={paymentMethod} onChange={(e) => setPaymentMethod(e.target.value as any)} className="input-field"> { value: 'all', label: inv.allStatuses },
<option value="all">{inv.allMethods}</option> ...(Object.keys(inv.status) as InvoiceStatus[]).map((value) => ({
<option value="wallet">{inv.methodWallet}</option> value,
<option value="gateway">{inv.methodGateway}</option> label: statusLabel(value),
<option value="mixed">{inv.methodMixed}</option> })),
</select> ]}
/>
<Select
size="md"
ariaLabel={inv.allMethods}
value={paymentMethod}
onChange={(v) => setPaymentMethod(v as any)}
options={[
{ value: 'all', label: inv.allMethods },
{ value: 'wallet', label: inv.methodWallet },
{ value: 'gateway', label: inv.methodGateway },
{ value: 'mixed', label: inv.methodMixed },
]}
/>
</div> </div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6"> <div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
@@ -8,6 +8,7 @@ import { useT, useLocale } from '@/i18n/I18nProvider';
import type { Cluster, ClusterPool } from '@/types'; import type { Cluster, ClusterPool } from '@/types';
import { Scale, BarChart3, RotateCw, CheckCircle, XCircle, Pencil, Clock, X } from 'lucide-react'; import { Scale, BarChart3, RotateCw, CheckCircle, XCircle, Pencil, Clock, X } from 'lucide-react';
import { useConfirm } from '@/components/confirm-modal'; import { useConfirm } from '@/components/confirm-modal';
import { Select } from '@/components/ui/select';
export default function AdminPoolsPage() { export default function AdminPoolsPage() {
const t = useT(); const t = useT();
@@ -150,18 +151,20 @@ export default function AdminPoolsPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">{p.strategy}</label> <label className="block text-sm font-medium text-gray-700 mb-1">{p.strategy}</label>
<select <Select
className="input-field" size="md"
ariaLabel={p.strategy}
value={form.strategy} value={form.strategy}
onChange={(e) => setForm({ ...form, strategy: e.target.value as any })} onChange={(v) => setForm({ ...form, strategy: v as any })}
> options={[
<option value="weighted-resource">{strategyOption('weighted-resource')}</option> { value: 'weighted-resource', label: strategyOption('weighted-resource') },
<option value="least-loaded">{strategyOption('least-loaded')}</option> { value: 'least-loaded', label: strategyOption('least-loaded') },
<option value="weighted-round-robin">{strategyOption('weighted-round-robin')}</option> { value: 'weighted-round-robin', label: strategyOption('weighted-round-robin') },
<option value="region-based">{strategyOption('region-based')}</option> { value: 'region-based', label: strategyOption('region-based') },
<option value="least-apps">{strategyOption('least-apps')}</option> { value: 'least-apps', label: strategyOption('least-apps') },
<option value="round-robin">{strategyOption('round-robin')}</option> { value: 'round-robin', label: strategyOption('round-robin') },
</select> ]}
/>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">{p.priority}</label> <label className="block text-sm font-medium text-gray-700 mb-1">{p.priority}</label>
@@ -8,6 +8,7 @@ import { useT, useLocale } from '@/i18n/I18nProvider';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import type { AdminUser } from '@/types'; import type { AdminUser } from '@/types';
import { Users, Search, X, Clock, KeyRound } from 'lucide-react'; import { Users, Search, X, Clock, KeyRound } from 'lucide-react';
import { Select } from '@/components/ui/select';
export default function AdminUsersPage() { export default function AdminUsersPage() {
const t = useT(); const t = useT();
@@ -166,16 +167,19 @@ export default function AdminUsersPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">{u.role}</label> <label className="block text-sm font-medium text-gray-700 mb-1">{u.role}</label>
<select <Select
className="input-field w-full sm:w-48" size="md"
className="w-full sm:w-48"
ariaLabel={u.role}
value={form.role} value={form.role}
onChange={(e) => setForm({ ...form, role: e.target.value as typeof form.role })} onChange={(v) => setForm({ ...form, role: v as typeof form.role })}
> options={[
<option value="user">{u.roles.user}</option> { value: 'user', label: u.roles.user },
<option value="admin">{u.roles.admin}</option> { value: 'admin', label: u.roles.admin },
<option value="technical">{u.roles.technical}</option> { value: 'technical', label: u.roles.technical },
<option value="sales">{u.roles.sales}</option> { value: 'sales', label: u.roles.sales },
</select> ]}
/>
</div> </div>
<button <button
onClick={() => createUser.mutate(form)} onClick={() => createUser.mutate(form)}
@@ -241,16 +245,18 @@ export default function AdminUsersPage() {
<td className="px-6 py-4 text-sm text-gray-600">{user.email}</td> <td className="px-6 py-4 text-sm text-gray-600">{user.email}</td>
<td className="px-6 py-4"> <td className="px-6 py-4">
{isAdmin ? ( {isAdmin ? (
<select <Select
className="text-sm border border-gray-200 rounded-lg px-2.5 py-1.5 bg-white focus:ring-2 focus:ring-primary-500 focus:border-transparent" className="w-40"
ariaLabel={u.role}
value={user.role} value={user.role}
onChange={(e) => changeRole.mutate({ id: user.id, role: e.target.value })} onChange={(v) => changeRole.mutate({ id: user.id, role: v })}
> options={[
<option value="user">{u.roles.user}</option> { value: 'user', label: u.roles.user },
<option value="admin">{u.roles.admin}</option> { value: 'admin', label: u.roles.admin },
<option value="technical">{u.roles.technical}</option> { value: 'technical', label: u.roles.technical },
<option value="sales">{u.roles.sales}</option> { value: 'sales', label: u.roles.sales },
</select> ]}
/>
) : ( ) : (
<span className={`badge ${ <span className={`badge ${
user.role === 'admin' ? 'badge-purple' : user.role === 'admin' ? 'badge-purple' :
@@ -322,16 +328,18 @@ export default function AdminUsersPage() {
</div> </div>
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-gray-100"> <div className="flex flex-wrap items-center gap-2 pt-2 border-t border-gray-100">
{isAdmin ? ( {isAdmin ? (
<select <Select
className="text-sm border border-gray-200 rounded-lg px-2.5 py-1.5 bg-white focus:ring-2 focus:ring-primary-500 focus:border-transparent" className="w-40"
ariaLabel={u.role}
value={user.role} value={user.role}
onChange={(e) => changeRole.mutate({ id: user.id, role: e.target.value })} onChange={(v) => changeRole.mutate({ id: user.id, role: v })}
> options={[
<option value="user">{u.roles.user}</option> { value: 'user', label: u.roles.user },
<option value="admin">{u.roles.admin}</option> { value: 'admin', label: u.roles.admin },
<option value="technical">{u.roles.technical}</option> { value: 'technical', label: u.roles.technical },
<option value="sales">{u.roles.sales}</option> { value: 'sales', label: u.roles.sales },
</select> ]}
/>
) : ( ) : (
<span className="text-sm text-gray-500">{roleLabel(user.role)}</span> <span className="text-sm text-gray-500">{roleLabel(user.role)}</span>
)} )}
@@ -19,6 +19,7 @@ import { useConfirm } from '@/components/confirm-modal';
import { useAuthStore } from '@/lib/store'; import { useAuthStore } from '@/lib/store';
import { useDeployProgressActions } from '@/lib/use-deploy-progress-actions'; import { useDeployProgressActions } from '@/lib/use-deploy-progress-actions';
import { useDeployProgressStore } from '@/lib/deploy-progress-store'; import { useDeployProgressStore } from '@/lib/deploy-progress-store';
import { Select } from '@/components/ui/select';
/** Matches backend multipart limit for POST /applications/:id/upload */ /** Matches backend multipart limit for POST /applications/:id/upload */
const MAX_SOURCE_ARCHIVE_BYTES = 10 * 1024 ** 3; const MAX_SOURCE_ARCHIVE_BYTES = 10 * 1024 ** 3;
@@ -2243,16 +2244,19 @@ export default function AppDetailPage() {
</p> </p>
<div className="mb-4"> <div className="mb-4">
<label className="block text-xs text-gray-500 mb-1">{ad.workload}</label> <label className="block text-xs text-gray-500 mb-1">{ad.workload}</label>
<select <Select
size="md"
className="max-w-md"
ariaLabel={ad.workload}
value={scaleWorkload} value={scaleWorkload}
onChange={(e) => setScaleWorkload(e.target.value as typeof scaleWorkload)} onChange={(v) => setScaleWorkload(v as typeof scaleWorkload)}
className="input-field text-sm max-w-md" options={[
> { value: 'app', label: ad.productTypeApp },
<option value="app">{ad.productTypeApp}</option> ...(app?.databaseType !== 'none' ? [{ value: 'database', label: ad.database }] : []),
{app?.databaseType !== 'none' && <option value="database">{ad.database}</option>} ...(app?.enableRedis ? [{ value: 'redis', label: ad.workload }] : []),
{app?.enableRedis && <option value="redis">{ad.workload}</option>} ...(app?.enableRabbitmq ? [{ value: 'rabbitmq', label: ad.workload }] : []),
{app?.enableRabbitmq && <option value="rabbitmq">{ad.workload}</option>} ]}
</select> />
</div> </div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
<div> <div>
+202 -196
View File
@@ -5,6 +5,7 @@ import { useMutation, useQuery } from '@tanstack/react-query';
import api from '@/lib/api'; import api from '@/lib/api';
import { useT, useLocale } from '@/i18n/I18nProvider'; import { useT, useLocale } from '@/i18n/I18nProvider';
import { useLocalizedRouter } from '@/i18n/navigation'; import { useLocalizedRouter } from '@/i18n/navigation';
import { Select } from '@/components/ui/select';
import { parseDotenv } from '@/lib/parseDotenv'; import { parseDotenv } from '@/lib/parseDotenv';
import { useAuthStore } from '@/lib/store'; import { useAuthStore } from '@/lib/store';
import { useDeployProgressStore } from '@/lib/deploy-progress-store'; import { useDeployProgressStore } from '@/lib/deploy-progress-store';
@@ -100,60 +101,68 @@ function WorkloadResourceFields({
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">{dw.cpuRequest}</label> <label className="block text-sm font-medium text-gray-700 mb-1">{dw.cpuRequest}</label>
<select <Select
className="input-field" size="md"
ariaLabel={dw.cpuRequest}
disabled={readOnly} disabled={readOnly}
value={config.cpuRequest || '50m'} value={config.cpuRequest || '50m'}
onChange={(e) => onChange({ cpuRequest: e.target.value })} onChange={(v) => onChange({ cpuRequest: v })}
> options={[
<option value="50m">50m (0.05 core)</option> { value: '50m', label: '50m (0.05 core)' },
<option value="100m">100m (0.1 core)</option> { value: '100m', label: '100m (0.1 core)' },
<option value="250m">250m (0.25 core)</option> { value: '250m', label: '250m (0.25 core)' },
<option value="500m">500m (0.5 core)</option> { value: '500m', label: '500m (0.5 core)' },
</select> ]}
/>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">{dw.cpuLimit}</label> <label className="block text-sm font-medium text-gray-700 mb-1">{dw.cpuLimit}</label>
<select <Select
className="input-field" size="md"
ariaLabel={dw.cpuLimit}
disabled={readOnly} disabled={readOnly}
value={config.cpuLimit} value={config.cpuLimit}
onChange={(e) => onChange({ cpuLimit: e.target.value })} onChange={(v) => onChange({ cpuLimit: v })}
> options={[
<option value="200m">200m (0.2 core)</option> { value: '200m', label: '200m (0.2 core)' },
<option value="250m">250m (0.25 core)</option> { value: '250m', label: '250m (0.25 core)' },
<option value="500m">500m (0.5 core)</option> { value: '500m', label: '500m (0.5 core)' },
<option value="1">1 core</option> { value: '1', label: '1 core' },
<option value="2">2 cores</option> { value: '2', label: '2 cores' },
</select> ]}
/>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">{dw.memoryRequest}</label> <label className="block text-sm font-medium text-gray-700 mb-1">{dw.memoryRequest}</label>
<select <Select
className="input-field" size="md"
ariaLabel={dw.memoryRequest}
disabled={readOnly} disabled={readOnly}
value={config.memoryRequest || '64Mi'} value={config.memoryRequest || '64Mi'}
onChange={(e) => onChange({ memoryRequest: e.target.value })} onChange={(v) => onChange({ memoryRequest: v })}
> options={[
<option value="64Mi">64 Mi</option> { value: '64Mi', label: '64 Mi' },
<option value="128Mi">128 Mi</option> { value: '128Mi', label: '128 Mi' },
<option value="256Mi">256 Mi</option> { value: '256Mi', label: '256 Mi' },
<option value="512Mi">512 Mi</option> { value: '512Mi', label: '512 Mi' },
</select> ]}
/>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">{dw.memoryLimit}</label> <label className="block text-sm font-medium text-gray-700 mb-1">{dw.memoryLimit}</label>
<select <Select
className="input-field" size="md"
ariaLabel={dw.memoryLimit}
disabled={readOnly} disabled={readOnly}
value={config.memoryLimit} value={config.memoryLimit}
onChange={(e) => onChange({ memoryLimit: e.target.value })} onChange={(v) => onChange({ memoryLimit: v })}
> options={[
<option value="256Mi">256 Mi</option> { value: '256Mi', label: '256 Mi' },
<option value="512Mi">512 Mi</option> { value: '512Mi', label: '512 Mi' },
<option value="1Gi">1 Gi</option> { value: '1Gi', label: '1 Gi' },
<option value="2Gi">2 Gi</option> { value: '2Gi', label: '2 Gi' },
</select> ]}
/>
</div> </div>
</div> </div>
<div className="mt-4"> <div className="mt-4">
@@ -1353,119 +1362,89 @@ export default function DeployPage() {
{form.runtime === 'nodejs' && ( {form.runtime === 'nodejs' && (
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">{dw.nodeVersion}</label> <label className="block text-sm font-medium text-gray-700 mb-1">{dw.nodeVersion}</label>
<select <Select
className="input-field" size="md"
value={form.runtimeVersion || '20'} value={form.runtimeVersion || '20'}
onChange={(e) => setForm({ ...form, runtimeVersion: e.target.value })} 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' }]}
<option value="22">Node.js 22 (LTS)</option> />
<option value="20">Node.js 20 (LTS)</option>
<option value="18">Node.js 18</option>
<option value="16">Node.js 16</option>
</select>
</div> </div>
)} )}
{form.runtime === 'laravel' && ( {form.runtime === 'laravel' && (
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">{dw.phpVersion}</label> <label className="block text-sm font-medium text-gray-700 mb-1">{dw.phpVersion}</label>
<select <Select
className="input-field" size="md"
value={form.phpVersion || '8.3'} value={form.phpVersion || '8.3'}
onChange={(e) => setForm({ ...form, phpVersion: e.target.value })} 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' }]}
<option value="8.4">PHP 8.4</option> />
<option value="8.3">PHP 8.3 (Recommended)</option>
<option value="8.2">PHP 8.2</option>
<option value="8.1">PHP 8.1</option>
</select>
</div> </div>
)} )}
{form.runtime === 'wordpress' && ( {form.runtime === 'wordpress' && (
<> <>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">{dw.wordpressVersion}</label> <label className="block text-sm font-medium text-gray-700 mb-1">{dw.wordpressVersion}</label>
<select <Select
className="input-field" size="md"
value={form.runtimeVersion || '6.7'} value={form.runtimeVersion || '6.7'}
onChange={(e) => setForm({ ...form, runtimeVersion: e.target.value })} onChange={(v) => 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' }]}
<option value="6.7">WordPress 6.7 (Latest)</option> />
<option value="6.6">WordPress 6.6</option>
<option value="6.5">WordPress 6.5</option>
<option value="6.4">WordPress 6.4</option>
</select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">{dw.phpVersion}</label> <label className="block text-sm font-medium text-gray-700 mb-1">{dw.phpVersion}</label>
<select <Select
className="input-field" size="md"
value={form.phpVersion || '8.3'} value={form.phpVersion || '8.3'}
onChange={(e) => setForm({ ...form, phpVersion: e.target.value })} onChange={(v) => 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' }]}
<option value="8.3">PHP 8.3 (Recommended)</option> />
<option value="8.2">PHP 8.2</option>
<option value="8.1">PHP 8.1</option>
</select>
</div> </div>
</> </>
)} )}
{form.runtime === 'go' && ( {form.runtime === 'go' && (
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">{dw.goVersion}</label> <label className="block text-sm font-medium text-gray-700 mb-1">{dw.goVersion}</label>
<select <Select
className="input-field" size="md"
value={form.runtimeVersion || '1.22'} value={form.runtimeVersion || '1.22'}
onChange={(e) => setForm({ ...form, runtimeVersion: e.target.value })} 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' }]}
<option value="1.23">Go 1.23 (Latest)</option> />
<option value="1.22">Go 1.22 (Recommended)</option>
<option value="1.21">Go 1.21</option>
</select>
</div> </div>
)} )}
{(form.runtime === 'python' || form.runtime === 'django') && ( {(form.runtime === 'python' || form.runtime === 'django') && (
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">{dw.pythonVersion}</label> <label className="block text-sm font-medium text-gray-700 mb-1">{dw.pythonVersion}</label>
<select <Select
className="input-field" size="md"
value={form.runtimeVersion || '3.12'} value={form.runtimeVersion || '3.12'}
onChange={(e) => setForm({ ...form, runtimeVersion: e.target.value })} 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' }]}
<option value="3.13">Python 3.13 (Latest)</option> />
<option value="3.12">Python 3.12 (Recommended)</option>
<option value="3.11">Python 3.11</option>
<option value="3.10">Python 3.10</option>
</select>
</div> </div>
)} )}
{form.runtime === 'php' && ( {form.runtime === 'php' && (
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">{dw.phpVersion}</label> <label className="block text-sm font-medium text-gray-700 mb-1">{dw.phpVersion}</label>
<select <Select
className="input-field" size="md"
value={form.phpVersion || '8.3'} value={form.phpVersion || '8.3'}
onChange={(e) => setForm({ ...form, phpVersion: e.target.value })} 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' }]}
<option value="8.4">PHP 8.4 (Latest)</option> />
<option value="8.3">PHP 8.3 (Recommended)</option>
<option value="8.2">PHP 8.2</option>
<option value="8.1">PHP 8.1</option>
</select>
</div> </div>
)} )}
{form.runtime === 'dotnet' && ( {form.runtime === 'dotnet' && (
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">.NET Version</label> <label className="block text-sm font-medium text-gray-700 mb-1">.NET Version</label>
<select <Select
className="input-field" size="md"
value={form.runtimeVersion || '8.0'} value={form.runtimeVersion || '8.0'}
onChange={(e) => setForm({ ...form, runtimeVersion: e.target.value })} 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' }]}
<option value="9.0">.NET 9.0 (Latest)</option> />
<option value="8.0">.NET 8.0 LTS (Recommended)</option>
<option value="7.0">.NET 7.0</option>
<option value="6.0">.NET 6.0 LTS</option>
</select>
</div> </div>
)} )}
</div> </div>
@@ -1518,40 +1497,40 @@ export default function DeployPage() {
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
{form.databaseType === 'postgresql' ? 'PostgreSQL' : form.databaseType === 'mysql' ? 'MySQL' : form.databaseType === 'mariadb' ? 'MariaDB' : 'MongoDB'} Version {form.databaseType === 'postgresql' ? 'PostgreSQL' : form.databaseType === 'mysql' ? 'MySQL' : form.databaseType === 'mariadb' ? 'MariaDB' : 'MongoDB'} Version
</label> </label>
<select <Select
className="input-field max-w-xs" size="md"
className="max-w-xs"
value={form.dbVersion || (form.databaseType === 'postgresql' ? '16' : form.databaseType === 'mysql' ? '8.0' : form.databaseType === 'mariadb' ? '11.4' : '7.0')} value={form.dbVersion || (form.databaseType === 'postgresql' ? '16' : form.databaseType === 'mysql' ? '8.0' : form.databaseType === 'mariadb' ? '11.4' : '7.0')}
onChange={(e) => setForm({ ...form, dbVersion: e.target.value })} onChange={(v) => setForm({ ...form, dbVersion: v })}
> options={
{form.databaseType === 'postgresql' ? ( form.databaseType === 'postgresql'
<> ? [
<option value="17">PostgreSQL 17 (Latest)</option> { value: '17', label: 'PostgreSQL 17 (Latest)' },
<option value="16">PostgreSQL 16 (LTS)</option> { value: '16', label: 'PostgreSQL 16 (LTS)' },
<option value="15">PostgreSQL 15</option> { value: '15', label: 'PostgreSQL 15' },
<option value="14">PostgreSQL 14</option> { value: '14', label: 'PostgreSQL 14' },
</> ]
) : form.databaseType === 'mysql' ? ( : form.databaseType === 'mysql'
<> ? [
<option value="9.0">MySQL 9.0 (Latest)</option> { value: '9.0', label: 'MySQL 9.0 (Latest)' },
<option value="8.4">MySQL 8.4 (LTS)</option> { value: '8.4', label: 'MySQL 8.4 (LTS)' },
<option value="8.0">MySQL 8.0</option> { value: '8.0', label: 'MySQL 8.0' },
<option value="5.7">MySQL 5.7</option> { value: '5.7', label: 'MySQL 5.7' },
</> ]
) : form.databaseType === 'mariadb' ? ( : form.databaseType === 'mariadb'
<> ? [
<option value="11.4">MariaDB 11.4 (LTS)</option> { value: '11.4', label: 'MariaDB 11.4 (LTS)' },
<option value="11.3">MariaDB 11.3</option> { value: '11.3', label: 'MariaDB 11.3' },
<option value="10.11">MariaDB 10.11 (LTS)</option> { value: '10.11', label: 'MariaDB 10.11 (LTS)' },
<option value="10.6">MariaDB 10.6</option> { value: '10.6', label: 'MariaDB 10.6' },
</> ]
) : ( : [
<> { value: '7.0', label: 'MongoDB 7.0 (Latest)' },
<option value="7.0">MongoDB 7.0 (Latest)</option> { value: '6.0', label: 'MongoDB 6.0' },
<option value="6.0">MongoDB 6.0</option> { value: '5.0', label: 'MongoDB 5.0' },
<option value="5.0">MongoDB 5.0</option> ]
</> }
)} />
</select>
</div> </div>
)} )}
@@ -1793,17 +1772,18 @@ export default function DeployPage() {
<div className="mt-3 pt-3 border-t border-red-200 space-y-2"> <div className="mt-3 pt-3 border-t border-red-200 space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-xs font-medium text-gray-600 shrink-0">{dw.versionColon}</label> <label className="text-xs font-medium text-gray-600 shrink-0">{dw.versionColon}</label>
<select <Select
className="select-compact flex-1 min-w-0" className="flex-1 min-w-0"
ariaLabel="Redis version"
value={form.redisVersion || '7.2'} value={form.redisVersion || '7.2'}
onChange={(e) => setForm({ ...form, redisVersion: e.target.value })} onChange={(v) => setForm({ ...form, redisVersion: v })}
onClick={(e) => e.stopPropagation()} options={[
> { value: '7.2', label: '7.2 (Latest)' },
<option value="7.2">7.2 (Latest)</option> { value: '7.0', label: '7.0' },
<option value="7.0">7.0</option> { value: '6.2', label: '6.2 (LTS)' },
<option value="6.2">6.2 (LTS)</option> { value: '6.0', label: '6.0' },
<option value="6.0">6.0</option> ]}
</select> />
</div> </div>
<p className="text-xs text-red-600">REDIS_HOST, REDIS_PASSWORD, REDIS_URL</p> <p className="text-xs text-red-600">REDIS_HOST, REDIS_PASSWORD, REDIS_URL</p>
</div> </div>
@@ -1851,17 +1831,18 @@ export default function DeployPage() {
<div className="mt-3 pt-3 border-t border-orange-200 space-y-2"> <div className="mt-3 pt-3 border-t border-orange-200 space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-xs font-medium text-gray-600 shrink-0">{dw.versionColon}</label> <label className="text-xs font-medium text-gray-600 shrink-0">{dw.versionColon}</label>
<select <Select
className="select-compact flex-1 min-w-0" className="flex-1 min-w-0"
ariaLabel="RabbitMQ version"
value={form.rabbitmqVersion || '3.13'} value={form.rabbitmqVersion || '3.13'}
onChange={(e) => setForm({ ...form, rabbitmqVersion: e.target.value })} onChange={(v) => setForm({ ...form, rabbitmqVersion: v })}
onClick={(e) => e.stopPropagation()} options={[
> { value: '3.13', label: '3.13 (Latest)' },
<option value="3.13">3.13 (Latest)</option> { value: '3.12', label: '3.12 (LTS)' },
<option value="3.12">3.12 (LTS)</option> { value: '3.11', label: '3.11' },
<option value="3.11">3.11</option> { value: '3.10', label: '3.10' },
<option value="3.10">3.10</option> ]}
</select> />
</div> </div>
<p className="text-xs text-orange-600">RABBITMQ_HOST, RABBITMQ_USER, AMQP_URL</p> <p className="text-xs text-orange-600">RABBITMQ_HOST, RABBITMQ_USER, AMQP_URL</p>
</div> </div>
@@ -1896,17 +1877,18 @@ export default function DeployPage() {
<div className="mt-3 pt-3 border-t border-yellow-200 space-y-2"> <div className="mt-3 pt-3 border-t border-yellow-200 space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-xs font-medium text-gray-600 shrink-0">{dw.versionColon}</label> <label className="text-xs font-medium text-gray-600 shrink-0">{dw.versionColon}</label>
<select <Select
className="select-compact flex-1 min-w-0" className="flex-1 min-w-0"
ariaLabel="Elasticsearch version"
value={form.elasticsearchVersion || '8.12'} value={form.elasticsearchVersion || '8.12'}
onChange={(e) => setForm({ ...form, elasticsearchVersion: e.target.value })} onChange={(v) => setForm({ ...form, elasticsearchVersion: v })}
onClick={(e) => e.stopPropagation()} options={[
> { value: '8.12', label: '8.12 (Latest)' },
<option value="8.12">8.12 (Latest)</option> { value: '8.11', label: '8.11' },
<option value="8.11">8.11</option> { value: '7.17', label: '7.17 (LTS)' },
<option value="7.17">7.17 (LTS)</option> { value: '7.10', label: '7.10' },
<option value="7.10">7.10</option> ]}
</select> />
</div> </div>
<p className="text-xs text-yellow-600">{dw.logsViaFluentBit}</p> <p className="text-xs text-yellow-600">{dw.logsViaFluentBit}</p>
</div> </div>
@@ -2305,39 +2287,63 @@ export default function DeployPage() {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">{dw.cpuRequest}</label> <label className="block text-sm font-medium text-gray-700 mb-1">{dw.cpuRequest}</label>
<select className="input-field" value={form.cpuRequest} onChange={(e) => setForm({ ...form, cpuRequest: e.target.value })}> <Select
<option value="50m">50m (0.05 core)</option> size="md"
<option value="100m">100m (0.1 core)</option> ariaLabel={dw.cpuRequest}
<option value="250m">250m (0.25 core)</option> value={form.cpuRequest || '100m'}
<option value="500m">500m (0.5 core)</option> onChange={(v) => setForm({ ...form, cpuRequest: v })}
</select> 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)' },
]}
/>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">{dw.cpuLimit}</label> <label className="block text-sm font-medium text-gray-700 mb-1">{dw.cpuLimit}</label>
<select className="input-field" value={form.cpuLimit} onChange={(e) => setForm({ ...form, cpuLimit: e.target.value })}> <Select
<option value="250m">250m (0.25 core)</option> size="md"
<option value="500m">500m (0.5 core)</option> ariaLabel={dw.cpuLimit}
<option value="1">1 core</option> value={form.cpuLimit || '500m'}
<option value="2">2 cores</option> onChange={(v) => setForm({ ...form, cpuLimit: v })}
</select> 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' },
]}
/>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">{dw.memoryRequest}</label> <label className="block text-sm font-medium text-gray-700 mb-1">{dw.memoryRequest}</label>
<select className="input-field" value={form.memoryRequest} onChange={(e) => setForm({ ...form, memoryRequest: e.target.value })}> <Select
<option value="64Mi">64 Mi</option> size="md"
<option value="128Mi">128 Mi</option> ariaLabel={dw.memoryRequest}
<option value="256Mi">256 Mi</option> value={form.memoryRequest || '128Mi'}
<option value="512Mi">512 Mi</option> onChange={(v) => setForm({ ...form, memoryRequest: v })}
</select> options={[
{ value: '64Mi', label: '64 Mi' },
{ value: '128Mi', label: '128 Mi' },
{ value: '256Mi', label: '256 Mi' },
{ value: '512Mi', label: '512 Mi' },
]}
/>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">{dw.memoryLimit}</label> <label className="block text-sm font-medium text-gray-700 mb-1">{dw.memoryLimit}</label>
<select className="input-field" value={form.memoryLimit} onChange={(e) => setForm({ ...form, memoryLimit: e.target.value })}> <Select
<option value="256Mi">256 Mi</option> size="md"
<option value="512Mi">512 Mi</option> ariaLabel={dw.memoryLimit}
<option value="1Gi">1 Gi</option> value={form.memoryLimit || '512Mi'}
<option value="2Gi">2 Gi</option> onChange={(v) => setForm({ ...form, memoryLimit: v })}
</select> options={[
{ value: '256Mi', label: '256 Mi' },
{ value: '512Mi', label: '512 Mi' },
{ value: '1Gi', label: '1 Gi' },
{ value: '2Gi', label: '2 Gi' },
]}
/>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
+43 -58
View File
@@ -6,6 +6,7 @@ import { useSearchParams } from 'next/navigation';
import api from '@/lib/api'; import api from '@/lib/api';
import { useT, useLocale } from '@/i18n/I18nProvider'; import { useT, useLocale } from '@/i18n/I18nProvider';
import type { Application, LogEntry, LogSearchResult, LogStatsResult } from '@/types'; import type { Application, LogEntry, LogSearchResult, LogStatsResult } from '@/types';
import { Select } from '@/components/ui/select';
import { import {
Loader2, Loader2,
RefreshCw, RefreshCw,
@@ -223,85 +224,69 @@ function LogsPageContent() {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3">
<div> <div>
<label className="text-xs font-medium text-gray-600 block mb-1">{lg.resource}</label> <label className="text-xs font-medium text-gray-600 block mb-1">{lg.resource}</label>
<select <Select
size="md"
ariaLabel={lg.resource}
value={appId} value={appId}
onChange={(e) => { onChange={(v) => {
setAppId(e.target.value); setAppId(v);
setPage(1); setPage(1);
}} }}
className="input w-full text-sm" options={[
> { value: '', label: lg.allResources },
<option value="">{lg.allResources}</option> ...(applications.length > 0
{applications.length > 0 && ( ? [
<optgroup label={lg.applicationsGroup}> { value: '__apps__', label: lg.applicationsGroup, disabled: true },
{applications.map((app) => ( ...applications.map((app) => ({ value: app.id, label: app.name })),
<option key={app.id} value={app.id}> ]
{app.name} : []),
</option> ...(managedServices.length > 0
))} ? [
</optgroup> { value: '__svcs__', label: lg.servicesGroup, disabled: true },
)} ...managedServices.map((svc) => ({ value: svc.id, label: svc.name })),
{managedServices.length > 0 && ( ]
<optgroup label={lg.servicesGroup}> : []),
{managedServices.map((svc) => ( ]}
<option key={svc.id} value={svc.id}> />
{svc.name}
</option>
))}
</optgroup>
)}
</select>
</div> </div>
<div> <div>
<label className="text-xs font-medium text-gray-600 block mb-1">{lg.source}</label> <label className="text-xs font-medium text-gray-600 block mb-1">{lg.source}</label>
<select <Select
size="md"
ariaLabel={lg.source}
value={workload} value={workload}
onChange={(e) => { onChange={(v) => {
setWorkload(e.target.value); setWorkload(v);
setPage(1); setPage(1);
}} }}
className="input w-full text-sm" options={workloadOptions.map((w) => ({ value: w.value, label: w.label }))}
> />
{workloadOptions.map((w) => (
<option key={w.value || 'all'} value={w.value}>
{w.label}
</option>
))}
</select>
</div> </div>
<div> <div>
<label className="text-xs font-medium text-gray-600 block mb-1">{lg.level}</label> <label className="text-xs font-medium text-gray-600 block mb-1">{lg.level}</label>
<select <Select
size="md"
ariaLabel={lg.level}
value={level} value={level}
onChange={(e) => { onChange={(v) => {
setLevel(e.target.value); setLevel(v);
setPage(1); setPage(1);
}} }}
className="input w-full text-sm" options={LOG_LEVELS.map((l) => ({ value: l.value, label: l.label }))}
> />
{LOG_LEVELS.map((l) => (
<option key={l.value || 'all'} value={l.value}>
{l.label}
</option>
))}
</select>
</div> </div>
<div> <div>
<label className="text-xs font-medium text-gray-600 block mb-1">{lg.timeRange}</label> <label className="text-xs font-medium text-gray-600 block mb-1">{lg.timeRange}</label>
<select <Select
size="md"
ariaLabel={lg.timeRange}
value={timeRange} value={timeRange}
onChange={(e) => { onChange={(v) => {
setTimeRange(e.target.value); setTimeRange(v);
setPage(1); setPage(1);
}} }}
className="input w-full text-sm" options={TIME_RANGES.map((tr) => ({ value: tr.value, label: tr.label }))}
> />
{TIME_RANGES.map((tr) => (
<option key={tr.value} value={tr.value}>
{tr.label}
</option>
))}
</select>
</div> </div>
<div> <div>
<label className="text-xs font-medium text-gray-600 block mb-1">{lg.search}</label> <label className="text-xs font-medium text-gray-600 block mb-1">{lg.search}</label>
@@ -7,6 +7,7 @@ import { toast } from 'react-toastify';
import { Link } from '@/i18n/Link'; import { Link } from '@/i18n/Link';
import { useT } from '@/i18n/I18nProvider'; import { useT } from '@/i18n/I18nProvider';
import { useLocalizedRouter } from '@/i18n/navigation'; import { useLocalizedRouter } from '@/i18n/navigation';
import { Select } from '@/components/ui/select';
import type { import type {
Application, Application,
CreateApplicationDto, CreateApplicationDto,
@@ -416,17 +417,14 @@ export default function NewManagedServicePage() {
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">{s.redisVersion}</label> <label className="block text-sm font-medium text-gray-700 mb-1">{s.redisVersion}</label>
<select <Select
className="input-field max-w-xs" size="md"
className="max-w-xs"
ariaLabel={s.redisVersion}
value={form.redisVersion} value={form.redisVersion}
onChange={(e) => setForm({ ...form, redisVersion: e.target.value })} onChange={(v) => setForm({ ...form, redisVersion: v })}
> options={['7.2', '7.0', '6.2', '6.0'].map((v) => ({ value: v, label: v }))}
{['7.2', '7.0', '6.2', '6.0'].map((v) => ( />
<option key={v} value={v}>
{v}
</option>
))}
</select>
</div> </div>
{form.optionalServiceResources?.redis && ( {form.optionalServiceResources?.redis && (
<OptionalServiceResourceFields <OptionalServiceResourceFields
@@ -453,17 +451,14 @@ export default function NewManagedServicePage() {
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">{s.rabbitmqVersion}</label> <label className="block text-sm font-medium text-gray-700 mb-1">{s.rabbitmqVersion}</label>
<select <Select
className="input-field max-w-xs" size="md"
className="max-w-xs"
ariaLabel={s.rabbitmqVersion}
value={form.rabbitmqVersion} value={form.rabbitmqVersion}
onChange={(e) => setForm({ ...form, rabbitmqVersion: e.target.value })} onChange={(v) => setForm({ ...form, rabbitmqVersion: v })}
> options={['3.13', '3.12', '3.11', '3.10'].map((v) => ({ value: v, label: v }))}
{['3.13', '3.12', '3.11', '3.10'].map((v) => ( />
<option key={v} value={v}>
{v}
</option>
))}
</select>
</div> </div>
{form.optionalServiceResources?.rabbitmq && ( {form.optionalServiceResources?.rabbitmq && (
<OptionalServiceResourceFields <OptionalServiceResourceFields
@@ -9,6 +9,7 @@ import { useAuthStore } from '@/lib/store';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import type { Ticket, CreateTicketDto, TicketDepartment, TicketPriority } from '@/types'; import type { Ticket, CreateTicketDto, TicketDepartment, TicketPriority } from '@/types';
import { Wrench, Briefcase, Ticket as TicketIcon, X } from 'lucide-react'; import { Wrench, Briefcase, Ticket as TicketIcon, X } from 'lucide-react';
import { Select } from '@/components/ui/select';
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
open: 'bg-yellow-100 text-yellow-700', open: 'bg-yellow-100 text-yellow-700',
@@ -103,27 +104,27 @@ export default function TicketsPage() {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">{tk.departmentField}</label> <label className="block text-sm font-medium text-gray-700 mb-1">{tk.departmentField}</label>
<select <Select
className="input-field" size="md"
ariaLabel={tk.departmentField}
value={form.department} value={form.department}
onChange={(e) => setForm({ ...form, department: e.target.value as TicketDepartment })} onChange={(v) => setForm({ ...form, department: v as TicketDepartment })}
> options={availableDepartments.map((dept) => ({ value: dept.value, label: dept.label }))}
{availableDepartments.map((dept) => ( />
<option key={dept.value} value={dept.value}>{dept.label}</option>
))}
</select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">{tk.priorityField}</label> <label className="block text-sm font-medium text-gray-700 mb-1">{tk.priorityField}</label>
<select <Select
className="input-field" size="md"
value={form.priority} ariaLabel={tk.priorityField}
onChange={(e) => setForm({ ...form, priority: e.target.value as TicketPriority })} value={form.priority || 'low'}
> onChange={(v) => setForm({ ...form, priority: v as TicketPriority })}
<option value="low">{tk.priority.low}</option> options={[
<option value="medium">{tk.priority.medium}</option> { value: 'low', label: tk.priority.low },
<option value="high">{tk.priority.high}</option> { value: 'medium', label: tk.priority.medium },
</select> { value: 'high', label: tk.priority.high },
]}
/>
</div> </div>
</div> </div>
@@ -2,6 +2,7 @@
import { Database } from 'lucide-react'; import { Database } from 'lucide-react';
import { useT } from '@/i18n/I18nProvider'; import { useT } from '@/i18n/I18nProvider';
import { Select } from '@/components/ui/select';
export interface DatabaseWorkloadValues { export interface DatabaseWorkloadValues {
cpuRequest: string; cpuRequest: string;
@@ -27,55 +28,63 @@ export function DatabaseWorkloadResources({
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className="block text-xs text-gray-500 mb-1">{r.cpuRequest}</label> <label className="block text-xs text-gray-500 mb-1">{r.cpuRequest}</label>
<select <Select
className="input-field text-sm" size="md"
ariaLabel={r.cpuRequest}
value={values.cpuRequest} value={values.cpuRequest}
onChange={(e) => onChange({ cpuRequest: e.target.value })} onChange={(v) => onChange({ cpuRequest: v })}
> options={[
<option value="50m">50m</option> { value: '50m', label: '50m' },
<option value="100m">100m</option> { value: '100m', label: '100m' },
<option value="250m">250m</option> { value: '250m', label: '250m' },
<option value="500m">500m</option> { value: '500m', label: '500m' },
</select> ]}
/>
</div> </div>
<div> <div>
<label className="block text-xs text-gray-500 mb-1">{r.cpuLimit}</label> <label className="block text-xs text-gray-500 mb-1">{r.cpuLimit}</label>
<select <Select
className="input-field text-sm" size="md"
ariaLabel={r.cpuLimit}
value={values.cpuLimit} value={values.cpuLimit}
onChange={(e) => onChange({ cpuLimit: e.target.value })} onChange={(v) => onChange({ cpuLimit: v })}
> options={[
<option value="250m">250m</option> { value: '250m', label: '250m' },
<option value="500m">500m</option> { value: '500m', label: '500m' },
<option value="1">{r.oneCore}</option> { value: '1', label: r.oneCore },
<option value="2">{r.twoCores}</option> { value: '2', label: r.twoCores },
</select> ]}
/>
</div> </div>
<div> <div>
<label className="block text-xs text-gray-500 mb-1">{r.memoryRequest}</label> <label className="block text-xs text-gray-500 mb-1">{r.memoryRequest}</label>
<select <Select
className="input-field text-sm" size="md"
ariaLabel={r.memoryRequest}
value={values.memoryRequest} value={values.memoryRequest}
onChange={(e) => onChange({ memoryRequest: e.target.value })} onChange={(v) => onChange({ memoryRequest: v })}
> options={[
<option value="64Mi">64 Mi</option> { value: '64Mi', label: '64 Mi' },
<option value="128Mi">128 Mi</option> { value: '128Mi', label: '128 Mi' },
<option value="256Mi">256 Mi</option> { value: '256Mi', label: '256 Mi' },
<option value="512Mi">512 Mi</option> { value: '512Mi', label: '512 Mi' },
</select> ]}
/>
</div> </div>
<div> <div>
<label className="block text-xs text-gray-500 mb-1">{r.memoryLimit}</label> <label className="block text-xs text-gray-500 mb-1">{r.memoryLimit}</label>
<select <Select
className="input-field text-sm" size="md"
ariaLabel={r.memoryLimit}
value={values.memoryLimit} value={values.memoryLimit}
onChange={(e) => onChange({ memoryLimit: e.target.value })} onChange={(v) => onChange({ memoryLimit: v })}
> options={[
<option value="256Mi">256 Mi</option> { value: '256Mi', label: '256 Mi' },
<option value="512Mi">512 Mi</option> { value: '512Mi', label: '512 Mi' },
<option value="1Gi">1 Gi</option> { value: '1Gi', label: '1 Gi' },
<option value="2Gi">2 Gi</option> { value: '2Gi', label: '2 Gi' },
</select> ]}
/>
</div> </div>
</div> </div>
</div> </div>
@@ -4,6 +4,7 @@ import { useRef, useState } from 'react';
import { Database, Eye, EyeOff, RefreshCw, AlertCircle } from 'lucide-react'; import { Database, Eye, EyeOff, RefreshCw, AlertCircle } from 'lucide-react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useT } from '@/i18n/I18nProvider'; import { useT } from '@/i18n/I18nProvider';
import { Select } from '@/components/ui/select';
import { ONE_GIB, minGiToFitFileBytes } from '@/lib/storage-utils'; import { ONE_GIB, minGiToFitFileBytes } from '@/lib/storage-utils';
export type DatabaseEngine = 'postgresql' | 'mysql' | 'mariadb' | 'mongodb'; export type DatabaseEngine = 'postgresql' | 'mysql' | 'mariadb' | 'mongodb';
@@ -101,17 +102,17 @@ export function ManagedDatabaseConfig({
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">{c.version}</label> <label className="block text-sm font-medium text-gray-700 mb-1">{c.version}</label>
<select <Select
className="input-field max-w-xs" size="md"
className="max-w-xs"
ariaLabel={c.version}
value={form.dbVersion} value={form.dbVersion}
onChange={(e) => onChange({ dbVersion: e.target.value })} onChange={(v) => onChange({ dbVersion: v })}
> options={versionOptions(form.databaseType).map((v) => ({
{versionOptions(form.databaseType).map((v) => ( value: v,
<option key={v} value={v}> label: versionLabel(form.databaseType, v),
{versionLabel(form.databaseType, v)} }))}
</option> />
))}
</select>
</div> </div>
<div className="p-4 bg-blue-50/50 border border-blue-200 rounded-xl space-y-4"> <div className="p-4 bg-blue-50/50 border border-blue-200 rounded-xl space-y-4">
@@ -2,6 +2,7 @@
import { Server } from 'lucide-react'; import { Server } from 'lucide-react';
import { useT } from '@/i18n/I18nProvider'; import { useT } from '@/i18n/I18nProvider';
import { Select } from '@/components/ui/select';
import type { OptionalServiceResourceConfig } from '@/types'; import type { OptionalServiceResourceConfig } from '@/types';
export function OptionalServiceResourceFields({ export function OptionalServiceResourceFields({
@@ -34,31 +35,35 @@ export function OptionalServiceResourceFields({
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className="block text-xs text-gray-500 mb-1">{r.cpuLimit}</label> <label className="block text-xs text-gray-500 mb-1">{r.cpuLimit}</label>
<select <Select
className="input-field text-sm" size="md"
ariaLabel={r.cpuLimit}
disabled={readOnly} disabled={readOnly}
value={config.cpuLimit} value={config.cpuLimit}
onChange={(e) => onChange({ cpuLimit: e.target.value })} onChange={(v) => onChange({ cpuLimit: v })}
> options={[
<option value="200m">200m</option> { value: '200m', label: '200m' },
<option value="500m">500m</option> { value: '500m', label: '500m' },
<option value="1">{r.oneCore}</option> { value: '1', label: r.oneCore },
<option value="2">{r.twoCores}</option> { value: '2', label: r.twoCores },
</select> ]}
/>
</div> </div>
<div> <div>
<label className="block text-xs text-gray-500 mb-1">{r.memoryLimit}</label> <label className="block text-xs text-gray-500 mb-1">{r.memoryLimit}</label>
<select <Select
className="input-field text-sm" size="md"
ariaLabel={r.memoryLimit}
disabled={readOnly} disabled={readOnly}
value={config.memoryLimit} value={config.memoryLimit}
onChange={(e) => onChange({ memoryLimit: e.target.value })} onChange={(v) => onChange({ memoryLimit: v })}
> options={[
<option value="256Mi">256 Mi</option> { value: '256Mi', label: '256 Mi' },
<option value="512Mi">512 Mi</option> { value: '512Mi', label: '512 Mi' },
<option value="1Gi">1 Gi</option> { value: '1Gi', label: '1 Gi' },
<option value="2Gi">2 Gi</option> { value: '2Gi', label: '2 Gi' },
</select> ]}
/>
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<label className="block text-xs text-gray-500 mb-1">{r.storageGb}</label> <label className="block text-xs text-gray-500 mb-1">{r.storageGb}</label>
@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api'; import api from '@/lib/api';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useT } from '@/i18n/I18nProvider'; import { useT } from '@/i18n/I18nProvider';
import { Select } from '@/components/ui/select';
import type { Application, ServiceAccessGrant, ServiceAccessTarget } from '@/types'; import type { Application, ServiceAccessGrant, ServiceAccessTarget } from '@/types';
import { ExternalLink, ShieldAlert, Copy, Check, Eye, EyeOff } from 'lucide-react'; import { ExternalLink, ShieldAlert, Copy, Check, Eye, EyeOff } from 'lucide-react';
@@ -129,17 +130,13 @@ export function ServiceExternalAccessPanel({
<div className="flex flex-wrap gap-4 items-end"> <div className="flex flex-wrap gap-4 items-end">
<div className="flex-1 min-w-[160px]"> <div className="flex-1 min-w-[160px]">
<label className="text-xs font-medium text-gray-600 block mb-1">{ea.service}</label> <label className="text-xs font-medium text-gray-600 block mb-1">{ea.service}</label>
<select <Select
size="md"
ariaLabel={ea.service}
value={accessTarget} value={accessTarget}
onChange={(e) => setAccessTarget(e.target.value as ServiceAccessTarget)} onChange={(v) => setAccessTarget(v as ServiceAccessTarget)}
className="input-field w-full text-sm" options={accessTargetOptions}
> />
{accessTargetOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div> </div>
<div className="flex-1 min-w-[200px]"> <div className="flex-1 min-w-[200px]">
<label className="text-xs font-medium text-gray-600 block mb-1">{ea.accessMode}</label> <label className="text-xs font-medium text-gray-600 block mb-1">{ea.accessMode}</label>
+127
View File
@@ -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 <select>, 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<HTMLDivElement>(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 (
<div ref={rootRef} className={`relative ${className}`}>
<button
type="button"
onClick={toggle}
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={ariaLabel}
className={`w-full flex items-center justify-between gap-2 border border-gray-300 bg-white text-sm text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 transition-all duration-150 disabled:opacity-60 disabled:cursor-not-allowed disabled:bg-gray-50 ${
size === 'md' ? 'rounded-xl px-3.5 py-2.5' : 'rounded-lg px-2.5 py-1.5'
}`}
>
<span className={`truncate ${selected ? '' : 'text-gray-400'}`}>
{selected?.label ?? placeholder ?? value}
</span>
<ChevronDown
className={`w-4 h-4 text-gray-400 shrink-0 transition-transform duration-150 ${open ? 'rotate-180' : ''}`}
/>
</button>
{open && (
<ul
role="listbox"
className={`absolute z-50 left-0 right-0 max-h-60 overflow-auto rounded-xl border border-gray-200 bg-white py-1 shadow-lg ring-1 ring-black/5 ${
flipUp ? 'bottom-full mb-1' : 'top-full mt-1'
}`}
>
{options.map((opt) => {
const isSelected = opt.value === value;
return (
<li
key={opt.value}
role="option"
aria-selected={isSelected}
aria-disabled={opt.disabled}
onClick={(e) => {
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'
}`}
>
<span className="truncate">{opt.label}</span>
{isSelected && <Check className="w-4 h-4 text-primary-600 shrink-0" />}
</li>
);
})}
</ul>
)}
</div>
);
}