fix(uptime): fix TCP monitor UX — correct format guidance and add client-side validation
The TCP monitor creation form showed a placeholder that instructed users to enter a URL with the tcp:// scheme prefix (e.g., tcp://192.168.1.1:8080). Following this guidance caused a silent HTTP 500 error because Go's net.SplitHostPort rejects any input containing a scheme prefix, expecting bare host:port format only. - Corrected the urlPlaceholder translation key to remove the tcp:// prefix - Added per-type dynamic placeholder (urlPlaceholderHttp / urlPlaceholderTcp) so the URL input shows the correct example format as soon as the user selects a monitor type - Added per-type helper text below the URL input explaining the required format, updated in real time when the type selector changes - Added client-side validation: typing a scheme prefix (://) in TCP mode shows an inline error and blocks form submission before the request reaches the backend - Reordered the Create Monitor form so the type selector appears before the URL input, giving users the correct format context before they type - Type selector onChange now clears any stale urlError to prevent incorrect error messages persisting after switching from TCP back to HTTP - Added 5 new i18n keys across all 5 supported locales (en, de, fr, es, zh) - Added 10 RTL unit tests covering all new validation paths including the type-change error-clear scenario - Added 9 Playwright E2E tests covering placeholder variants, helper text, inline error lifecycle, submission blocking, and successful TCP creation Closes #issue-5 (TCP monitor UI cannot add monitor when following placeholder)
This commit is contained in:
@@ -407,7 +407,13 @@
|
||||
"monitorDeleted": "Monitor gelöscht",
|
||||
"deleteConfirm": "Diesen Monitor löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||
"pending": "PRÜFUNG...",
|
||||
"pendingFirstCheck": "Warten auf erste Prüfung..."
|
||||
"pendingFirstCheck": "Warten auf erste Prüfung...",
|
||||
"urlPlaceholder": "https://example.com",
|
||||
"urlPlaceholderHttp": "https://example.com",
|
||||
"urlPlaceholderTcp": "192.168.1.1:8080",
|
||||
"urlHelperHttp": "Vollständige URL einschließlich Schema eingeben (z.B. https://example.com)",
|
||||
"urlHelperTcp": "Als Host:Port ohne Schema-Präfix eingeben (z.B. 192.168.1.1:8080 oder hostname:22)",
|
||||
"invalidTcpFormat": "TCP-Monitore erfordern das Format Host:Port. Entfernen Sie das Schema-Präfix (z.B. 192.168.1.1:8080 statt tcp://192.168.1.1:8080)."
|
||||
},
|
||||
"domains": {
|
||||
"title": "Domänen",
|
||||
|
||||
@@ -477,7 +477,12 @@
|
||||
"monitorUrl": "URL",
|
||||
"monitorTypeHttp": "HTTP",
|
||||
"monitorTypeTcp": "TCP",
|
||||
"urlPlaceholder": "https://example.com or tcp://host:port",
|
||||
"urlPlaceholder": "https://example.com",
|
||||
"urlPlaceholderHttp": "https://example.com",
|
||||
"urlPlaceholderTcp": "192.168.1.1:8080",
|
||||
"urlHelperHttp": "Enter the full URL including the scheme (e.g., https://example.com)",
|
||||
"urlHelperTcp": "Enter as host:port with no scheme prefix (e.g., 192.168.1.1:8080 or hostname:22)",
|
||||
"invalidTcpFormat": "TCP monitors require host:port format. Remove the scheme prefix (e.g., use 192.168.1.1:8080, not tcp://192.168.1.1:8080).",
|
||||
"pending": "CHECKING...",
|
||||
"pendingFirstCheck": "Waiting for first check..."
|
||||
},
|
||||
|
||||
@@ -407,7 +407,13 @@
|
||||
"monitorDeleted": "Monitor eliminado",
|
||||
"deleteConfirm": "¿Eliminar este monitor? Esto no se puede deshacer.",
|
||||
"pending": "VERIFICANDO...",
|
||||
"pendingFirstCheck": "Esperando primera verificación..."
|
||||
"pendingFirstCheck": "Esperando primera verificación...",
|
||||
"urlPlaceholder": "https://example.com",
|
||||
"urlPlaceholderHttp": "https://example.com",
|
||||
"urlPlaceholderTcp": "192.168.1.1:8080",
|
||||
"urlHelperHttp": "Ingresa la URL completa incluyendo el esquema (ej. https://example.com)",
|
||||
"urlHelperTcp": "Ingresa como host:puerto sin prefijo de esquema (ej. 192.168.1.1:8080 o nombre-de-host:22)",
|
||||
"invalidTcpFormat": "Los monitores TCP requieren el formato host:puerto. Elimina el prefijo de esquema (ej. usa 192.168.1.1:8080, no tcp://192.168.1.1:8080)."
|
||||
},
|
||||
"domains": {
|
||||
"title": "Dominios",
|
||||
|
||||
@@ -407,7 +407,13 @@
|
||||
"monitorDeleted": "Moniteur supprimé",
|
||||
"deleteConfirm": "Supprimer ce moniteur? Cette action est irréversible.",
|
||||
"pending": "VÉRIFICATION...",
|
||||
"pendingFirstCheck": "En attente de la première vérification..."
|
||||
"pendingFirstCheck": "En attente de la première vérification...",
|
||||
"urlPlaceholder": "https://example.com",
|
||||
"urlPlaceholderHttp": "https://example.com",
|
||||
"urlPlaceholderTcp": "192.168.1.1:8080",
|
||||
"urlHelperHttp": "Saisissez l'URL complète avec le schéma (ex. https://example.com)",
|
||||
"urlHelperTcp": "Saisissez sous la forme hôte:port sans préfixe de schéma (ex. 192.168.1.1:8080 ou nom-d-hôte:22)",
|
||||
"invalidTcpFormat": "Les moniteurs TCP nécessitent le format hôte:port. Supprimez le préfixe de schéma (ex. 192.168.1.1:8080 et non tcp://192.168.1.1:8080)."
|
||||
},
|
||||
"domains": {
|
||||
"title": "Domaines",
|
||||
|
||||
@@ -407,7 +407,13 @@
|
||||
"monitorDeleted": "监控器已删除",
|
||||
"deleteConfirm": "删除此监控器?此操作无法撤销。",
|
||||
"pending": "检查中...",
|
||||
"pendingFirstCheck": "等待首次检查..."
|
||||
"pendingFirstCheck": "等待首次检查...",
|
||||
"urlPlaceholder": "https://example.com",
|
||||
"urlPlaceholderHttp": "https://example.com",
|
||||
"urlPlaceholderTcp": "192.168.1.1:8080",
|
||||
"urlHelperHttp": "请输入包含协议的完整 URL(例如 https://example.com)",
|
||||
"urlHelperTcp": "请输入 主机:端口 格式,不含协议前缀(例如 192.168.1.1:8080 或 hostname:22)",
|
||||
"invalidTcpFormat": "TCP 监控器需要 主机:端口 格式。请删除协议前缀(例如使用 192.168.1.1:8080,而非 tcp://192.168.1.1:8080)。"
|
||||
},
|
||||
"domains": {
|
||||
"title": "域名",
|
||||
|
||||
@@ -346,6 +346,11 @@ const CreateMonitorModal: FC<{ onClose: () => void; t: (key: string) => string }
|
||||
const [type, setType] = useState<'http' | 'tcp'>('http');
|
||||
const [interval, setInterval] = useState(60);
|
||||
const [maxRetries, setMaxRetries] = useState(3);
|
||||
const [urlError, setUrlError] = useState('');
|
||||
|
||||
const urlPlaceholder = type === 'tcp'
|
||||
? t('uptime.urlPlaceholderTcp')
|
||||
: t('uptime.urlPlaceholderHttp');
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: { name: string; url: string; type: string; interval?: number; max_retries?: number }) =>
|
||||
@@ -363,6 +368,10 @@ const CreateMonitorModal: FC<{ onClose: () => void; t: (key: string) => string }
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !url.trim()) return;
|
||||
if (type === 'tcp' && url.trim().includes('://')) {
|
||||
setUrlError(t('uptime.invalidTcpFormat'));
|
||||
return;
|
||||
}
|
||||
mutation.mutate({ name: name.trim(), url: url.trim(), type, interval, max_retries: maxRetries });
|
||||
};
|
||||
|
||||
@@ -399,6 +408,24 @@ const CreateMonitorModal: FC<{ onClose: () => void; t: (key: string) => string }
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="create-monitor-type" className="block text-sm font-medium text-gray-300 mb-1">
|
||||
{t('uptime.monitorType')} *
|
||||
</label>
|
||||
<select
|
||||
id="create-monitor-type"
|
||||
value={type}
|
||||
onChange={(e) => {
|
||||
setType(e.target.value as 'http' | 'tcp');
|
||||
setUrlError('');
|
||||
}}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="http">{t('uptime.monitorTypeHttp')}</option>
|
||||
<option value="tcp">{t('uptime.monitorTypeTcp')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="create-monitor-url" className="block text-sm font-medium text-gray-300 mb-1">
|
||||
{t('uptime.monitorUrl')} *
|
||||
@@ -407,26 +434,35 @@ const CreateMonitorModal: FC<{ onClose: () => void; t: (key: string) => string }
|
||||
id="create-monitor-url"
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setUrl(val);
|
||||
if (type === 'tcp' && val.includes('://')) {
|
||||
setUrlError(t('uptime.invalidTcpFormat'));
|
||||
} else {
|
||||
setUrlError('');
|
||||
}
|
||||
}}
|
||||
required
|
||||
aria-describedby={`create-monitor-url-helper${urlError ? ' create-monitor-url-error' : ''}`}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder={t('uptime.urlPlaceholder')}
|
||||
placeholder={urlPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="create-monitor-type" className="block text-sm font-medium text-gray-300 mb-1">
|
||||
{t('uptime.monitorType')} *
|
||||
</label>
|
||||
<select
|
||||
id="create-monitor-type"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as 'http' | 'tcp')}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
<p
|
||||
id="create-monitor-url-helper"
|
||||
className="text-xs text-gray-400 mt-1"
|
||||
>
|
||||
<option value="http">{t('uptime.monitorTypeHttp')}</option>
|
||||
<option value="tcp">{t('uptime.monitorTypeTcp')}</option>
|
||||
</select>
|
||||
{type === 'tcp' ? t('uptime.urlHelperTcp') : t('uptime.urlHelperHttp')}
|
||||
</p>
|
||||
{urlError && (
|
||||
<p
|
||||
id="create-monitor-url-error"
|
||||
className="text-xs text-red-400 mt-1"
|
||||
role="alert"
|
||||
>
|
||||
{urlError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
265
frontend/src/pages/__tests__/Uptime.tcp-ux.test.tsx
Normal file
265
frontend/src/pages/__tests__/Uptime.tcp-ux.test.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
|
||||
import Uptime from '../Uptime'
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
const translations: Record<string, string> = {
|
||||
'uptime.title': 'Uptime Monitoring',
|
||||
'uptime.loadingMonitors': 'Loading monitors...',
|
||||
'uptime.noMonitorsFound': 'No monitors found',
|
||||
'uptime.syncWithHosts': 'Sync with Hosts',
|
||||
'uptime.syncing': 'Syncing...',
|
||||
'uptime.addMonitor': 'Add Monitor',
|
||||
'uptime.autoRefreshing': 'Auto-refreshing every 30s',
|
||||
'uptime.proxyHosts': 'Proxy Hosts',
|
||||
'uptime.remoteServers': 'Remote Servers',
|
||||
'uptime.otherMonitors': 'Other Monitors',
|
||||
'uptime.latency': 'Latency',
|
||||
'uptime.lastCheck': 'Last Check',
|
||||
'uptime.never': 'Never',
|
||||
'uptime.configureMonitor': 'Configure Monitor',
|
||||
'uptime.createMonitor': 'Create Monitor',
|
||||
'uptime.monitorSettings': 'Monitor Settings',
|
||||
'uptime.triggerHealthCheck': 'Trigger Health Check',
|
||||
'uptime.paused': 'Paused',
|
||||
'uptime.pause': 'Pause',
|
||||
'uptime.unpause': 'Resume',
|
||||
'uptime.maxRetries': 'Max Retries',
|
||||
'uptime.maxRetriesHelper': 'Number of retries before marking as down',
|
||||
'uptime.checkInterval': 'Check Interval',
|
||||
'uptime.saveChanges': 'Save Changes',
|
||||
'uptime.monitorUrl': 'Monitor URL',
|
||||
'uptime.urlPlaceholder': 'https://example.com',
|
||||
'uptime.urlPlaceholderHttp': 'https://example.com',
|
||||
'uptime.urlPlaceholderTcp': '192.168.1.1:8080',
|
||||
'uptime.urlHelperHttp': 'Enter the full URL including the scheme',
|
||||
'uptime.urlHelperTcp': 'Enter as host:port with no scheme prefix',
|
||||
'uptime.invalidTcpFormat': 'TCP monitors require host:port format. Remove the scheme prefix.',
|
||||
'uptime.monitorType': 'Monitor Type',
|
||||
'uptime.monitorTypeHttp': 'HTTP(S)',
|
||||
'uptime.monitorTypeTcp': 'TCP',
|
||||
'uptime.noHistoryAvailable': 'No history available',
|
||||
'uptime.last60Checks': 'Last 60 Checks',
|
||||
'common.configure': 'Configure',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.delete': 'Delete',
|
||||
'common.create': 'Create',
|
||||
'common.saving': 'Saving...',
|
||||
'common.name': 'Name',
|
||||
'common.close': 'Close',
|
||||
}
|
||||
if (options && typeof options === 'object') {
|
||||
let result = translations[key] || key
|
||||
for (const [k, v] of Object.entries(options)) {
|
||||
result = result.replace(`{{${k}}}`, String(v))
|
||||
}
|
||||
return result
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock uptime API
|
||||
vi.mock('../../api/uptime', () => ({
|
||||
getMonitors: vi.fn(),
|
||||
getMonitorHistory: vi.fn(),
|
||||
updateMonitor: vi.fn(),
|
||||
deleteMonitor: vi.fn(),
|
||||
checkMonitor: vi.fn(),
|
||||
createMonitor: vi.fn(),
|
||||
syncMonitors: vi.fn(),
|
||||
}))
|
||||
|
||||
async function openCreateModal() {
|
||||
const { getMonitors } = await import('../../api/uptime')
|
||||
vi.mocked(getMonitors).mockResolvedValue([])
|
||||
|
||||
renderWithQueryClient(<Uptime />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('add-monitor-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByTestId('add-monitor-button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Create Monitor')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
describe('CreateMonitorModal — TCP UX', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders HTTP placeholder by default', async () => {
|
||||
await openCreateModal()
|
||||
|
||||
const urlInput = screen.getByLabelText(/Monitor URL/)
|
||||
expect(urlInput).toHaveAttribute('placeholder', 'https://example.com')
|
||||
})
|
||||
|
||||
it('renders TCP placeholder when type is TCP', async () => {
|
||||
const user = await openCreateModal()
|
||||
|
||||
const typeSelect = screen.getByLabelText(/Monitor Type/)
|
||||
await user.selectOptions(typeSelect, 'tcp')
|
||||
|
||||
await waitFor(() => {
|
||||
const urlInput = screen.getByLabelText(/Monitor URL/)
|
||||
expect(urlInput).toHaveAttribute('placeholder', '192.168.1.1:8080')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows HTTP helper text by default', async () => {
|
||||
await openCreateModal()
|
||||
|
||||
const helper = document.getElementById('create-monitor-url-helper')
|
||||
expect(helper).toBeInTheDocument()
|
||||
expect(helper?.textContent).toContain('scheme')
|
||||
})
|
||||
|
||||
it('shows TCP helper text when type is TCP', async () => {
|
||||
const user = await openCreateModal()
|
||||
|
||||
const typeSelect = screen.getByLabelText(/Monitor Type/)
|
||||
await user.selectOptions(typeSelect, 'tcp')
|
||||
|
||||
await waitFor(() => {
|
||||
const helper = document.getElementById('create-monitor-url-helper')
|
||||
expect(helper?.textContent).toContain('host:port')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows inline error when tcp:// entered in TCP mode', async () => {
|
||||
const user = await openCreateModal()
|
||||
|
||||
const typeSelect = screen.getByLabelText(/Monitor Type/)
|
||||
await user.selectOptions(typeSelect, 'tcp')
|
||||
|
||||
const urlInput = screen.getByLabelText(/Monitor URL/)
|
||||
await user.type(urlInput, 'tcp://192.168.1.1:8080')
|
||||
|
||||
await waitFor(() => {
|
||||
const alert = screen.getByRole('alert')
|
||||
expect(alert).toBeInTheDocument()
|
||||
expect(alert.textContent).toContain('host:port format')
|
||||
})
|
||||
})
|
||||
|
||||
it('inline error clears when scheme prefix removed', async () => {
|
||||
const user = await openCreateModal()
|
||||
|
||||
const typeSelect = screen.getByLabelText(/Monitor Type/)
|
||||
await user.selectOptions(typeSelect, 'tcp')
|
||||
|
||||
const urlInput = screen.getByLabelText(/Monitor URL/)
|
||||
await user.type(urlInput, 'tcp://192.168.1.1:8080')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await user.clear(urlInput)
|
||||
await user.type(urlInput, '192.168.1.1:8080')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('inline error clears when type changes from TCP to HTTP', async () => {
|
||||
const user = await openCreateModal()
|
||||
|
||||
const typeSelect = screen.getByLabelText(/Monitor Type/)
|
||||
await user.selectOptions(typeSelect, 'tcp')
|
||||
|
||||
const urlInput = screen.getByLabelText(/Monitor URL/)
|
||||
await user.type(urlInput, 'tcp://192.168.1.1:8080')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await user.selectOptions(typeSelect, 'http')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('handleSubmit blocked when tcp:// in URL while type is TCP', async () => {
|
||||
const { createMonitor } = await import('../../api/uptime')
|
||||
const user = await openCreateModal()
|
||||
|
||||
const typeSelect = screen.getByLabelText(/Monitor Type/)
|
||||
await user.selectOptions(typeSelect, 'tcp')
|
||||
|
||||
const nameInput = screen.getByLabelText(/Name/)
|
||||
await user.type(nameInput, 'My TCP Monitor')
|
||||
|
||||
const urlInput = screen.getByLabelText(/Monitor URL/)
|
||||
await user.type(urlInput, 'tcp://192.168.1.1:8080')
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /Create/ })
|
||||
await user.click(submitButton)
|
||||
|
||||
expect(createMonitor).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handleSubmit proceeds when TCP URL is bare host:port', async () => {
|
||||
const { createMonitor } = await import('../../api/uptime')
|
||||
vi.mocked(createMonitor).mockResolvedValue({
|
||||
id: 'new-1',
|
||||
name: 'DB Server',
|
||||
type: 'tcp',
|
||||
url: '192.168.1.1:5432',
|
||||
interval: 60,
|
||||
enabled: true,
|
||||
status: 'pending',
|
||||
latency: 0,
|
||||
max_retries: 3,
|
||||
})
|
||||
|
||||
const user = await openCreateModal()
|
||||
|
||||
const typeSelect = screen.getByLabelText(/Monitor Type/)
|
||||
await user.selectOptions(typeSelect, 'tcp')
|
||||
|
||||
const nameInput = screen.getByLabelText(/Name/)
|
||||
await user.type(nameInput, 'DB Server')
|
||||
|
||||
const urlInput = screen.getByLabelText(/Monitor URL/)
|
||||
await user.type(urlInput, '192.168.1.1:5432')
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /Create/ })
|
||||
await user.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createMonitor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: '192.168.1.1:5432', type: 'tcp' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('type selector appears before URL input in DOM order', async () => {
|
||||
await openCreateModal()
|
||||
|
||||
const typeSelect = screen.getByLabelText(/Monitor Type/)
|
||||
const urlInput = screen.getByLabelText(/Monitor URL/)
|
||||
|
||||
// Node.DOCUMENT_POSITION_FOLLOWING (4) means typeSelect comes before urlInput
|
||||
const position = typeSelect.compareDocumentPosition(urlInput)
|
||||
expect(position & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -37,7 +37,12 @@ vi.mock('react-i18next', () => ({
|
||||
'uptime.checkInterval': 'Check Interval',
|
||||
'uptime.saveChanges': 'Save Changes',
|
||||
'uptime.monitorUrl': 'Monitor URL',
|
||||
'uptime.urlPlaceholder': 'https://example.com or host:port',
|
||||
'uptime.urlPlaceholder': 'https://example.com',
|
||||
'uptime.urlPlaceholderHttp': 'https://example.com',
|
||||
'uptime.urlPlaceholderTcp': '192.168.1.1:8080',
|
||||
'uptime.urlHelperHttp': 'Enter the full URL including the scheme',
|
||||
'uptime.urlHelperTcp': 'Enter as host:port with no scheme prefix',
|
||||
'uptime.invalidTcpFormat': 'TCP monitors require host:port format. Remove the scheme prefix.',
|
||||
'uptime.monitorType': 'Monitor Type',
|
||||
'uptime.monitorTypeHttp': 'HTTP(S)',
|
||||
'uptime.monitorTypeTcp': 'TCP',
|
||||
|
||||
Reference in New Issue
Block a user