feat: add Docker container management functionality

- Implement DockerHandler to handle API requests for listing Docker containers.
- Create DockerService to interact with Docker API and retrieve container information.
- Add routes for Docker container management in the API.
- Introduce frontend API integration for Docker container listing.
- Enhance ProxyHostForm to allow quick selection of Docker containers.
- Update Docker-related tests to ensure functionality and error handling.
- Modify Docker Compose files to enable Docker socket access for local and remote environments.
- Add TypeScript configurations for improved build processes.
This commit is contained in:
Wikid82
2025-11-20 21:27:02 -05:00
parent 8c67e656b9
commit 9f62a4a2df
27 changed files with 691 additions and 71 deletions

View File

@@ -0,0 +1,26 @@
import client from './client'
export interface DockerPort {
private_port: number
public_port: number
type: string
}
export interface DockerContainer {
id: string
names: string[]
image: string
state: string
status: string
network: string
ip: string
ports: DockerPort[]
}
export const dockerApi = {
listContainers: async (host?: string): Promise<DockerContainer[]> => {
const params = host ? { host } : undefined
const response = await client.get<DockerContainer[]>('/docker/containers', { params })
return response.data
},
}

View File

@@ -11,7 +11,7 @@ export interface ImportPreview {
session: ImportSession;
preview: {
hosts: Array<{ domain_names: string; [key: string]: unknown }>;
conflicts: Record<string, string>;
conflicts: string[];
errors: string[];
};
}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'
import type { ProxyHost } from '../api/proxyHosts'
import { useRemoteServers } from '../hooks/useRemoteServers'
import { useDocker } from '../hooks/useDocker'
interface ProxyHostFormProps {
host?: ProxyHost
@@ -25,6 +26,9 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
})
const { servers: remoteServers } = useRemoteServers()
const [dockerHost, setDockerHost] = useState('')
const [showDockerHost, setShowDockerHost] = useState(false)
const { containers: dockerContainers, isLoading: dockerLoading, error: dockerError } = useDocker(dockerHost)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -54,6 +58,23 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
}
}
const handleContainerSelect = (containerId: string) => {
const container = dockerContainers.find(c => c.id === containerId)
if (container) {
// Prefer internal IP if available, otherwise use container name
const host = container.ip || container.names[0]
// Use the first exposed port if available, otherwise default to 80
const port = container.ports && container.ports.length > 0 ? container.ports[0].private_port : 80
setFormData({
...formData,
forward_host: host,
forward_port: port,
forward_scheme: 'http',
})
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-dark-card rounded-lg border border-gray-800 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
@@ -85,26 +106,75 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
/>
</div>
{/* Remote Server Quick Select */}
{remoteServers.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Remote Server Quick Select */}
{remoteServers.length > 0 && (
<div>
<label htmlFor="quick-select-server" className="block text-sm font-medium text-gray-300 mb-2">
Quick Select: Remote Server
</label>
<select
id="quick-select-server"
onChange={e => handleServerSelect(e.target.value)}
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="">-- Select a server --</option>
{remoteServers.map(server => (
<option key={server.uuid} value={server.uuid}>
{server.name} ({server.host}:{server.port})
</option>
))}
</select>
</div>
)}
{/* Docker Container Quick Select */}
<div>
<label htmlFor="quick-select" className="block text-sm font-medium text-gray-300 mb-2">
Quick Select from Remote Servers
</label>
<div className="flex justify-between items-center mb-2">
<label htmlFor="quick-select-docker" className="block text-sm font-medium text-gray-300">
Quick Select: Container
</label>
<button
type="button"
onClick={() => setShowDockerHost(!showDockerHost)}
className="text-xs text-blue-400 hover:text-blue-300"
>
{showDockerHost ? 'Hide Remote' : 'Remote Docker?'}
</button>
</div>
{showDockerHost && (
<input
type="text"
placeholder="tcp://100.x.y.z:2375"
value={dockerHost}
onChange={(e) => setDockerHost(e.target.value)}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white text-sm mb-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
)}
<select
id="quick-select"
onChange={e => handleServerSelect(e.target.value)}
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"
id="quick-select-docker"
onChange={e => handleContainerSelect(e.target.value)}
disabled={dockerLoading}
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 disabled:opacity-50"
>
<option value="">-- Select a server --</option>
{remoteServers.map(server => (
<option key={server.uuid} value={server.uuid}>
{server.name} ({server.host}:{server.port})
<option value="">
{dockerLoading ? 'Loading containers...' : '-- Select a container --'}
</option>
{dockerContainers.map(container => (
<option key={container.id} value={container.id}>
{container.names[0]} ({container.image})
</option>
))}
</select>
{dockerError && (
<p className="text-xs text-red-400 mt-1">
Failed to connect: {(dockerError as Error).message}
</p>
)}
</div>
)}
</div>
{/* Forward Details */}
<div className="grid grid-cols-3 gap-4">

View File

@@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import ProxyHostForm from '../ProxyHostForm'
import { mockRemoteServers } from '../../test/mockData'
// Mock the hook
// Mock the hooks
vi.mock('../../hooks/useRemoteServers', () => ({
useRemoteServers: vi.fn(() => ({
servers: mockRemoteServers,
@@ -16,6 +16,26 @@ vi.mock('../../hooks/useRemoteServers', () => ({
})),
}))
vi.mock('../../hooks/useDocker', () => ({
useDocker: vi.fn(() => ({
containers: [
{
id: 'container-123',
names: ['my-app'],
image: 'nginx:latest',
state: 'running',
status: 'Up 2 hours',
network: 'bridge',
ip: '172.17.0.2',
ports: [{ private_port: 80, public_port: 8080, type: 'tcp' }]
}
],
isLoading: false,
error: null,
refetch: vi.fn(),
})),
}))
const queryClient = new QueryClient({
defaultOptions: {
queries: {
@@ -65,6 +85,7 @@ describe('ProxyHostForm', () => {
block_exploits: true,
websocket_support: false,
enabled: true,
locations: [],
created_at: '2025-11-18T10:00:00Z',
updated_at: '2025-11-18T10:00:00Z',
}
@@ -159,13 +180,29 @@ describe('ProxyHostForm', () => {
expect(screen.getByText(/Local Docker Registry/)).toBeInTheDocument()
})
const select = screen.getByRole('combobox', { name: /quick select/i })
const select = screen.getByLabelText('Quick Select: Remote Server')
fireEvent.change(select, { target: { value: mockRemoteServers[0].uuid } })
expect(screen.getByDisplayValue(mockRemoteServers[0].host)).toBeInTheDocument()
expect(screen.getByDisplayValue(mockRemoteServers[0].port)).toBeInTheDocument()
})
it('populates fields when a docker container is selected', async () => {
renderWithClient(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await waitFor(() => {
expect(screen.getByLabelText('Quick Select: Container')).toBeInTheDocument()
})
const select = screen.getByLabelText('Quick Select: Container')
fireEvent.change(select, { target: { value: 'container-123' } })
expect(screen.getByDisplayValue('172.17.0.2')).toBeInTheDocument() // IP
expect(screen.getByDisplayValue('80')).toBeInTheDocument() // Port
})
it('displays error message on submission failure', async () => {
const mockErrorSubmit = vi.fn(() => Promise.reject(new Error('Submission failed')))
renderWithClient(
@@ -199,4 +236,19 @@ describe('ProxyHostForm', () => {
expect(advancedInput).toHaveValue('header_up X-Test "True"')
})
it('allows entering a remote docker host', async () => {
renderWithClient(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
const toggle = screen.getByText('Remote Docker?')
fireEvent.click(toggle)
const input = screen.getByPlaceholderText('tcp://100.x.y.z:2375')
expect(input).toBeInTheDocument()
fireEvent.change(input, { target: { value: 'tcp://remote:2375' } })
expect(input).toHaveValue('tcp://remote:2375')
})
})

View File

@@ -49,22 +49,26 @@ describe('useImport', () => {
it('uploads content and creates session', async () => {
const mockSession = {
uuid: 'session-1',
filename: 'Caddyfile',
state: 'reviewing',
id: 'session-1',
state: 'reviewing' as const,
created_at: '2025-01-18T10:00:00Z',
updated_at: '2025-01-18T10:00:00Z',
}
const mockPreview = {
hosts: [{ domain: 'test.com' }],
const mockPreviewData = {
hosts: [{ domain_names: 'test.com' }],
conflicts: [],
errors: [],
}
vi.mocked(api.uploadCaddyfile).mockResolvedValue({ session: mockSession })
const mockResponse = {
session: mockSession,
preview: mockPreviewData,
}
vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse)
vi.mocked(api.getImportStatus).mockResolvedValue({ has_pending: true, session: mockSession })
vi.mocked(api.getImportPreview).mockResolvedValue(mockPreview)
vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse)
const { result } = renderHook(() => useImport(), { wrapper: createWrapper() })
@@ -103,20 +107,24 @@ describe('useImport', () => {
it('commits import with resolutions', async () => {
const mockSession = {
uuid: 'session-2',
filename: 'Caddyfile',
state: 'reviewing',
id: 'session-2',
state: 'reviewing' as const,
created_at: '2025-01-18T10:00:00Z',
updated_at: '2025-01-18T10:00:00Z',
}
const mockResponse = {
session: mockSession,
preview: { hosts: [], conflicts: [], errors: [] },
}
let isCommitted = false
vi.mocked(api.uploadCaddyfile).mockResolvedValue({ session: mockSession })
vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse)
vi.mocked(api.getImportStatus).mockImplementation(async () => {
if (isCommitted) return { has_pending: false }
return { has_pending: true, session: mockSession }
})
vi.mocked(api.getImportPreview).mockResolvedValue({ hosts: [], conflicts: [], errors: [] })
vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse)
vi.mocked(api.commitImport).mockImplementation(async () => {
isCommitted = true
})
@@ -144,20 +152,24 @@ describe('useImport', () => {
it('cancels active import session', async () => {
const mockSession = {
uuid: 'session-3',
filename: 'Caddyfile',
state: 'reviewing',
id: 'session-3',
state: 'reviewing' as const,
created_at: '2025-01-18T10:00:00Z',
updated_at: '2025-01-18T10:00:00Z',
}
const mockResponse = {
session: mockSession,
preview: { hosts: [], conflicts: [], errors: [] },
}
let isCancelled = false
vi.mocked(api.uploadCaddyfile).mockResolvedValue({ session: mockSession })
vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse)
vi.mocked(api.getImportStatus).mockImplementation(async () => {
if (isCancelled) return { has_pending: false }
return { has_pending: true, session: mockSession }
})
vi.mocked(api.getImportPreview).mockResolvedValue({ hosts: [], conflicts: [], errors: [] })
vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse)
vi.mocked(api.cancelImport).mockImplementation(async () => {
isCancelled = true
})
@@ -184,16 +196,20 @@ describe('useImport', () => {
it('handles commit errors', async () => {
const mockSession = {
uuid: 'session-4',
filename: 'Caddyfile',
state: 'reviewing',
id: 'session-4',
state: 'reviewing' as const,
created_at: '2025-01-18T10:00:00Z',
updated_at: '2025-01-18T10:00:00Z',
}
vi.mocked(api.uploadCaddyfile).mockResolvedValue({ session: mockSession })
const mockResponse = {
session: mockSession,
preview: { hosts: [], conflicts: [], errors: [] },
}
vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse)
vi.mocked(api.getImportStatus).mockResolvedValue({ has_pending: true, session: mockSession })
vi.mocked(api.getImportPreview).mockResolvedValue({ hosts: [], conflicts: [], errors: [] })
vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse)
const mockError = new Error('Commit failed')
vi.mocked(api.commitImport).mockRejectedValue(mockError)

View File

@@ -13,6 +13,25 @@ vi.mock('../../api/proxyHosts', () => ({
deleteProxyHost: vi.fn(),
}))
const createMockHost = (overrides: Partial<api.ProxyHost> = {}): api.ProxyHost => ({
uuid: '1',
domain_names: 'test.com',
forward_scheme: 'http',
forward_host: 'localhost',
forward_port: 8080,
ssl_forced: false,
http2_support: false,
hsts_enabled: false,
hsts_subdomains: false,
block_exploits: false,
websocket_support: false,
locations: [],
enabled: true,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
...overrides,
})
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
@@ -37,8 +56,8 @@ describe('useProxyHosts', () => {
it('loads proxy hosts on mount', async () => {
const mockHosts = [
{ uuid: '1', domain_names: 'test.com', enabled: true, forward_host: 'localhost', forward_port: 8080 },
{ uuid: '2', domain_names: 'app.com', enabled: true, forward_host: 'localhost', forward_port: 3000 },
createMockHost({ uuid: '1', domain_names: 'test.com', enabled: true, forward_host: 'localhost', forward_port: 8080 }),
createMockHost({ uuid: '2', domain_names: 'app.com', enabled: true, forward_host: 'localhost', forward_port: 3000 }),
]
vi.mocked(api.getProxyHosts).mockResolvedValue(mockHosts)
@@ -74,7 +93,7 @@ describe('useProxyHosts', () => {
it('creates a new proxy host', async () => {
vi.mocked(api.getProxyHosts).mockResolvedValue([])
const newHost = { domain_names: 'new.com', forward_host: 'localhost', forward_port: 9000 }
const createdHost = { uuid: '3', ...newHost, enabled: true }
const createdHost = createMockHost({ uuid: '3', ...newHost, enabled: true })
vi.mocked(api.createProxyHost).mockImplementation(async () => {
vi.mocked(api.getProxyHosts).mockResolvedValue([createdHost])
@@ -98,12 +117,11 @@ describe('useProxyHosts', () => {
})
it('updates an existing proxy host', async () => {
const existingHost = { uuid: '1', domain_names: 'test.com', enabled: true, forward_host: 'localhost', forward_port: 8080 }
const existingHost = createMockHost({ uuid: '1', domain_names: 'test.com', enabled: true, forward_host: 'localhost', forward_port: 8080 })
let hosts = [existingHost]
vi.mocked(api.getProxyHosts).mockImplementation(() => Promise.resolve(hosts))
const updatedHost = { ...existingHost, domain_names: 'updated.com' }
vi.mocked(api.updateProxyHost).mockImplementation(async (uuid, data) => {
vi.mocked(api.updateProxyHost).mockImplementation(async (_, data) => {
hosts = [{ ...existingHost, ...data }]
return hosts[0]
})
@@ -126,8 +144,8 @@ describe('useProxyHosts', () => {
it('deletes a proxy host', async () => {
const hosts = [
{ uuid: '1', domain_names: 'test.com', enabled: true, forward_host: 'localhost', forward_port: 8080 },
{ uuid: '2', domain_names: 'app.com', enabled: true, forward_host: 'localhost', forward_port: 3000 },
createMockHost({ uuid: '1', domain_names: 'test.com', enabled: true, forward_host: 'localhost', forward_port: 8080 }),
createMockHost({ uuid: '2', domain_names: 'app.com', enabled: true, forward_host: 'localhost', forward_port: 3000 }),
]
vi.mocked(api.getProxyHosts).mockResolvedValue(hosts)
vi.mocked(api.deleteProxyHost).mockImplementation(async (uuid) => {
@@ -167,7 +185,7 @@ describe('useProxyHosts', () => {
})
it('handles update errors', async () => {
const host = { uuid: '1', domain_names: 'test.com', enabled: true, forward_host: 'localhost', forward_port: 8080 }
const host = createMockHost({ uuid: '1', domain_names: 'test.com', enabled: true, forward_host: 'localhost', forward_port: 8080 })
vi.mocked(api.getProxyHosts).mockResolvedValue([host])
const mockError = new Error('Failed to update')
vi.mocked(api.updateProxyHost).mockRejectedValue(mockError)
@@ -182,7 +200,7 @@ describe('useProxyHosts', () => {
})
it('handles delete errors', async () => {
const host = { uuid: '1', domain_names: 'test.com', enabled: true, forward_host: 'localhost', forward_port: 8080 }
const host = createMockHost({ uuid: '1', domain_names: 'test.com', enabled: true, forward_host: 'localhost', forward_port: 8080 })
vi.mocked(api.getProxyHosts).mockResolvedValue([host])
const mockError = new Error('Failed to delete')
vi.mocked(api.deleteProxyHost).mockRejectedValue(mockError)

View File

@@ -14,6 +14,19 @@ vi.mock('../../api/remoteServers', () => ({
testRemoteServerConnection: vi.fn(),
}))
const createMockServer = (overrides: Partial<api.RemoteServer> = {}): api.RemoteServer => ({
uuid: '1',
name: 'Server 1',
provider: 'generic',
host: 'localhost',
port: 8080,
enabled: true,
reachable: true,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
...overrides,
})
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
@@ -38,8 +51,8 @@ describe('useRemoteServers', () => {
it('loads all remote servers on mount', async () => {
const mockServers = [
{ uuid: '1', name: 'Server 1', host: 'localhost', port: 8080, enabled: true },
{ uuid: '2', name: 'Server 2', host: '192.168.1.100', port: 3000, enabled: false },
createMockServer({ uuid: '1', name: 'Server 1', host: 'localhost', port: 8080, enabled: true }),
createMockServer({ uuid: '2', name: 'Server 2', host: '192.168.1.100', port: 3000, enabled: false }),
]
vi.mocked(api.getRemoteServers).mockResolvedValue(mockServers)
@@ -75,7 +88,7 @@ describe('useRemoteServers', () => {
it('creates a new remote server', async () => {
vi.mocked(api.getRemoteServers).mockResolvedValue([])
const newServer = { name: 'New Server', host: 'new.local', port: 5000, provider: 'generic' }
const createdServer = { uuid: '4', ...newServer, enabled: true }
const createdServer = createMockServer({ uuid: '4', ...newServer, enabled: true })
vi.mocked(api.createRemoteServer).mockImplementation(async () => {
vi.mocked(api.getRemoteServers).mockResolvedValue([createdServer])
@@ -99,12 +112,11 @@ describe('useRemoteServers', () => {
})
it('updates an existing remote server', async () => {
const existingServer = { uuid: '1', name: 'Server 1', host: 'localhost', port: 8080, enabled: true }
const existingServer = createMockServer({ uuid: '1', name: 'Server 1', host: 'localhost', port: 8080, enabled: true })
let servers = [existingServer]
vi.mocked(api.getRemoteServers).mockImplementation(() => Promise.resolve(servers))
const updatedServer = { ...existingServer, name: 'Updated Server' }
vi.mocked(api.updateRemoteServer).mockImplementation(async (uuid, data) => {
vi.mocked(api.updateRemoteServer).mockImplementation(async (_, data) => {
servers = [{ ...existingServer, ...data }]
return servers[0]
})
@@ -127,8 +139,8 @@ describe('useRemoteServers', () => {
it('deletes a remote server', async () => {
const servers = [
{ uuid: '1', name: 'Server 1', host: 'localhost', port: 8080, enabled: true },
{ uuid: '2', name: 'Server 2', host: '192.168.1.100', port: 3000, enabled: false },
createMockServer({ uuid: '1', name: 'Server 1', host: 'localhost', port: 8080, enabled: true }),
createMockServer({ uuid: '2', name: 'Server 2', host: '192.168.1.100', port: 3000, enabled: false }),
]
vi.mocked(api.getRemoteServers).mockResolvedValue(servers)
vi.mocked(api.deleteRemoteServer).mockImplementation(async (uuid) => {
@@ -185,7 +197,7 @@ describe('useRemoteServers', () => {
})
it('handles update errors', async () => {
const server = { uuid: '1', name: 'Server 1', host: 'localhost', port: 8080, enabled: true }
const server = createMockServer({ uuid: '1', name: 'Server 1', host: 'localhost', port: 8080, enabled: true })
vi.mocked(api.getRemoteServers).mockResolvedValue([server])
const mockError = new Error('Failed to update')
vi.mocked(api.updateRemoteServer).mockRejectedValue(mockError)
@@ -196,11 +208,11 @@ describe('useRemoteServers', () => {
expect(result.current.loading).toBe(false)
})
await expect(result.current.updateServer('1', { name: 'Updated' })).rejects.toThrow('Failed to update')
await expect(result.current.updateServer('1', { name: 'Updated Server' })).rejects.toThrow('Failed to update')
})
it('handles delete errors', async () => {
const server = { uuid: '1', name: 'Server 1', host: 'localhost', port: 8080, enabled: true }
const server = createMockServer({ uuid: '1', name: 'Server 1', host: 'localhost', port: 8080, enabled: true })
vi.mocked(api.getRemoteServers).mockResolvedValue([server])
const mockError = new Error('Failed to delete')
vi.mocked(api.deleteRemoteServer).mockRejectedValue(mockError)

View File

@@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query'
import { dockerApi } from '../api/docker'
export function useDocker(host?: string) {
const {
data: containers = [],
isLoading,
error,
refetch,
} = useQuery({
queryKey: ['docker-containers', host],
queryFn: () => dockerApi.listContainers(host),
retry: 1, // Don't retry too much if docker is not available
})
return {
containers,
isLoading,
error,
refetch,
}
}

View File

@@ -134,7 +134,7 @@ api.example.com {
{showReview && preview && preview.preview && (
<ImportReviewTable
hosts={preview.preview.hosts}
conflicts={Object.keys(preview.preview.conflicts)}
conflicts={preview.preview.conflicts}
errors={preview.preview.errors}
onCommit={handleCommit}
onCancel={() => setShowReview(false)}

View File

@@ -8,14 +8,13 @@ export const mockProxyHosts: ProxyHost[] = [
forward_scheme: 'http',
forward_host: 'localhost',
forward_port: 3000,
access_list_id: undefined,
certificate_id: undefined,
ssl_forced: false,
http2_support: true,
hsts_enabled: false,
hsts_subdomains: false,
block_exploits: true,
websocket_support: true,
locations: [],
advanced_config: undefined,
enabled: true,
created_at: '2025-11-18T10:00:00Z',
@@ -27,14 +26,13 @@ export const mockProxyHosts: ProxyHost[] = [
forward_scheme: 'http',
forward_host: '192.168.1.100',
forward_port: 8080,
access_list_id: undefined,
certificate_id: undefined,
ssl_forced: false,
http2_support: true,
hsts_enabled: false,
hsts_subdomains: false,
block_exploits: true,
websocket_support: false,
locations: [],
advanced_config: undefined,
enabled: true,
created_at: '2025-11-18T10:00:00Z',