305 lines
12 KiB
TypeScript
305 lines
12 KiB
TypeScript
import { Execution, ID, Query } from 'appwrite'
|
|
import { FormEvent, useEffect, useState } from 'react'
|
|
import {
|
|
appwriteCollectionCloudflareAccountsId,
|
|
appwriteDatabaseId,
|
|
appwriteCollectionServersId,
|
|
databases,
|
|
functions,
|
|
} from '../lib/appwrite'
|
|
|
|
const badgeClass = (status: string) =>
|
|
status === 'active' ? 'rounded-full bg-emerald-500/20 px-2 py-1 text-xs text-emerald-200' : 'rounded-full bg-amber-500/20 px-2 py-1 text-xs text-amber-200'
|
|
|
|
type Zone = {
|
|
id: string
|
|
name: string
|
|
status: string
|
|
paused?: boolean
|
|
}
|
|
|
|
type Worker = {
|
|
name: string
|
|
modifiedOn?: string
|
|
active?: boolean
|
|
}
|
|
|
|
export default function Cloudflare() {
|
|
const [label, setLabel] = useState('')
|
|
const [apiKey, setApiKey] = useState('')
|
|
const [accountId, setAccountId] = useState('')
|
|
const [cloudflareAccountId, setCloudflareAccountId] = useState('')
|
|
const [credentials, setCredentials] = useState<{ $id: string; label: string }[]>([])
|
|
const [zones, setZones] = useState<Zone[]>([])
|
|
const [workers, setWorkers] = useState<Worker[]>([])
|
|
const [execution, setExecution] = useState<Execution | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [modalOpen, setModalOpen] = useState(false)
|
|
const [newZone, setNewZone] = useState('')
|
|
const [onlineCount, setOnlineCount] = useState<number | null>(null)
|
|
|
|
useEffect(() => {
|
|
const loadCredentials = async () => {
|
|
if (!appwriteDatabaseId || !appwriteCollectionCloudflareAccountsId) return
|
|
try {
|
|
const response = await databases.listDocuments(appwriteDatabaseId, appwriteCollectionCloudflareAccountsId, [
|
|
Query.equal('provider', 'cloudflare'),
|
|
])
|
|
const docs = response.documents.map((doc) => ({ $id: doc.$id, label: (doc as { label?: string }).label || doc.$id }))
|
|
setCredentials(docs)
|
|
} catch (err) {
|
|
console.error(err)
|
|
setError('Falha ao carregar credenciais Cloudflare.')
|
|
}
|
|
}
|
|
|
|
const loadWorkers = async () => {
|
|
if (!appwriteDatabaseId || !appwriteCollectionServersId) return
|
|
try {
|
|
const servers = await databases.listDocuments<{ status?: string }>(appwriteDatabaseId, appwriteCollectionServersId, [
|
|
Query.equal('status', 'online'),
|
|
])
|
|
setOnlineCount(servers.total)
|
|
} catch (err) {
|
|
console.error(err)
|
|
}
|
|
}
|
|
|
|
loadCredentials()
|
|
loadWorkers()
|
|
}, [])
|
|
|
|
const handleSaveKey = async (event: FormEvent) => {
|
|
event.preventDefault()
|
|
setError(null)
|
|
if (!appwriteDatabaseId || !appwriteCollectionCloudflareAccountsId) {
|
|
setError('Configure VITE_APPWRITE_DATABASE_ID e VITE_APPWRITE_COLLECTION_CLOUDFLARE_ACCOUNTS_ID para salvar a chave de API.')
|
|
return
|
|
}
|
|
|
|
try {
|
|
const document = await databases.createDocument(appwriteDatabaseId, appwriteCollectionCloudflareAccountsId, ID.unique(), {
|
|
provider: 'cloudflare',
|
|
apiKey,
|
|
label,
|
|
})
|
|
setCredentials((prev) => [...prev, { $id: document.$id, label: label || document.$id }])
|
|
setLabel('')
|
|
setApiKey('')
|
|
} catch (err) {
|
|
console.error(err)
|
|
setError('Não foi possível salvar a API Key.')
|
|
}
|
|
}
|
|
|
|
const handleStatus = async () => {
|
|
setError(null)
|
|
const selectedAccount = accountId || credentials[0]?.$id
|
|
if (!selectedAccount) {
|
|
setError('Selecione ou cadastre uma credencial Cloudflare antes de consultar o status.')
|
|
return
|
|
}
|
|
try {
|
|
const executionResult = await functions.createExecution(
|
|
'check-cloudflare-status',
|
|
JSON.stringify({ accountId: selectedAccount, cloudflareAccountId }),
|
|
)
|
|
setExecution(executionResult)
|
|
|
|
const payload = executionResult.responseBody ? JSON.parse(executionResult.responseBody) : {}
|
|
setZones((payload.zones as Zone[]) || [])
|
|
setWorkers((payload.workers as Worker[]) || [])
|
|
} catch (err) {
|
|
console.error(err)
|
|
setError('Não foi possível checar o status do Cloudflare.')
|
|
}
|
|
}
|
|
|
|
const handleAddZone = () => {
|
|
if (!newZone.trim()) return
|
|
setZones((prev) => [{ id: crypto.randomUUID(), name: newZone, status: 'pending' }, ...prev])
|
|
setNewZone('')
|
|
setModalOpen(false)
|
|
}
|
|
|
|
const online = zones.some((zone) => zone.status === 'active' && !zone.paused)
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<header className="rounded-xl border border-slate-800/70 bg-slate-900/60 p-5 shadow-lg shadow-slate-950/50">
|
|
<p className="text-xs uppercase tracking-[0.3em] text-cyan-300">Cloudflare</p>
|
|
<h1 className="mt-2 text-2xl font-semibold text-slate-50">Integração e Status</h1>
|
|
<p className="mt-2 text-sm text-slate-400">
|
|
Salve a API Key criptografada no Appwrite e consulte a função <span className="text-cyan-300">check-cloudflare-status</span>.
|
|
</p>
|
|
</header>
|
|
|
|
<div className="grid gap-4 lg:grid-cols-2">
|
|
<form className="rounded-xl border border-slate-800/70 bg-slate-900/70 p-5 shadow-inner shadow-slate-950/60" onSubmit={handleSaveKey}>
|
|
<h2 className="text-lg font-semibold text-slate-100">Salvar credencial</h2>
|
|
<p className="text-sm text-slate-400">Armazene a chave com criptografia gerenciada pelo Appwrite.</p>
|
|
|
|
<div className="mt-4 space-y-3">
|
|
<div>
|
|
<label className="text-sm text-slate-200">Label</label>
|
|
<input
|
|
type="text"
|
|
value={label}
|
|
onChange={(e) => setLabel(e.target.value)}
|
|
className="mt-1 w-full rounded-lg border border-slate-800 bg-slate-950 px-3 py-2 text-sm text-slate-100 outline-none focus:border-cyan-400"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm text-slate-200">API Key</label>
|
|
<input
|
|
type="password"
|
|
value={apiKey}
|
|
onChange={(e) => setApiKey(e.target.value)}
|
|
required
|
|
className="mt-1 w-full rounded-lg border border-slate-800 bg-slate-950 px-3 py-2 text-sm text-slate-100 outline-none focus:border-cyan-400"
|
|
/>
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
className="rounded-lg bg-cyan-500 px-4 py-2 text-sm font-semibold text-slate-900 transition hover:bg-cyan-400"
|
|
>
|
|
Salvar API Key
|
|
</button>
|
|
</div>
|
|
{error ? <p className="mt-3 text-sm text-red-300">{error}</p> : null}
|
|
</form>
|
|
|
|
<div className="rounded-xl border border-slate-800/70 bg-slate-900/70 p-5 shadow-inner shadow-slate-950/60">
|
|
<h2 className="text-lg font-semibold text-slate-100">Status das zonas</h2>
|
|
<p className="text-sm text-slate-400">Selecione a credencial e consulte o status em tempo real.</p>
|
|
|
|
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
<div>
|
|
<label className="text-sm text-slate-200">Credencial</label>
|
|
<select
|
|
value={accountId}
|
|
onChange={(e) => setAccountId(e.target.value)}
|
|
className="mt-1 w-full rounded-lg border border-slate-800 bg-slate-950 px-3 py-2 text-sm text-slate-100 outline-none focus:border-cyan-400"
|
|
>
|
|
<option value="">Selecione um documento</option>
|
|
{credentials.map((cred) => (
|
|
<option key={cred.$id} value={cred.$id}>
|
|
{cred.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm text-slate-200">Cloudflare Account ID</label>
|
|
<input
|
|
type="text"
|
|
value={cloudflareAccountId}
|
|
onChange={(e) => setCloudflareAccountId(e.target.value)}
|
|
className="mt-1 w-full rounded-lg border border-slate-800 bg-slate-950 px-3 py-2 text-sm text-slate-100 outline-none focus:border-cyan-400"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={handleStatus}
|
|
className="rounded-lg bg-cyan-500 px-4 py-2 text-sm font-semibold text-slate-900 transition hover:bg-cyan-400"
|
|
>
|
|
Consultar status
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setModalOpen(true)}
|
|
className="rounded-lg border border-cyan-500/60 px-4 py-2 text-sm text-cyan-200 transition hover:bg-cyan-500/10"
|
|
>
|
|
Adicionar Nova Zona
|
|
</button>
|
|
</div>
|
|
|
|
{execution ? (
|
|
<div className="mt-3 rounded-lg border border-slate-800 bg-slate-950/60 p-3 text-xs text-slate-300">
|
|
Execução #{execution.$id} • Status {execution.status}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-4 lg:grid-cols-2">
|
|
<div className="rounded-xl border border-slate-800/70 bg-slate-900/70 p-5">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-lg font-semibold text-slate-100">Zonas DNS</h3>
|
|
<span className={online ? 'text-xs text-emerald-300' : 'text-xs text-amber-300'}>
|
|
{online ? 'Online' : 'Aguardando verificação'}
|
|
</span>
|
|
</div>
|
|
<ul className="mt-3 divide-y divide-slate-800 text-sm text-slate-200">
|
|
{zones.length === 0 ? <li className="py-3 text-slate-500">Nenhuma zona carregada.</li> : null}
|
|
{zones.map((zone) => (
|
|
<li key={zone.id} className="flex items-center justify-between py-3">
|
|
<div>
|
|
<p className="font-semibold">{zone.name}</p>
|
|
<p className="text-xs text-slate-400">ID: {zone.id}</p>
|
|
</div>
|
|
<span className={badgeClass(zone.status)}>{zone.status}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
|
|
<div className="rounded-xl border border-slate-800/70 bg-slate-900/70 p-5">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-lg font-semibold text-slate-100">Workers</h3>
|
|
<span className="text-xs text-slate-400">Ativos no Appwrite: {onlineCount ?? '---'}</span>
|
|
</div>
|
|
<ul className="mt-3 divide-y divide-slate-800 text-sm text-slate-200">
|
|
{workers.length === 0 ? <li className="py-3 text-slate-500">Nenhum worker listado.</li> : null}
|
|
{workers.map((worker) => (
|
|
<li key={worker.name} className="flex items-center justify-between py-3">
|
|
<div>
|
|
<p className="font-semibold">{worker.name}</p>
|
|
<p className="text-xs text-slate-400">{worker.modifiedOn}</p>
|
|
</div>
|
|
<span className={worker.active ? 'text-xs text-emerald-300' : 'text-xs text-slate-400'}>
|
|
{worker.active ? 'Ativo' : 'Inativo'}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
{modalOpen ? (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
|
|
<div className="w-full max-w-md rounded-xl border border-slate-800 bg-slate-950 p-6 shadow-2xl shadow-slate-900">
|
|
<h4 className="text-lg font-semibold text-slate-100">Adicionar Nova Zona</h4>
|
|
<p className="mt-1 text-sm text-slate-400">Inclua uma entrada manual enquanto a função sincroniza zonas.</p>
|
|
<input
|
|
type="text"
|
|
value={newZone}
|
|
onChange={(e) => setNewZone(e.target.value)}
|
|
placeholder="example.com"
|
|
className="mt-3 w-full rounded-lg border border-slate-800 bg-slate-900 px-3 py-2 text-sm text-slate-100 outline-none focus:border-cyan-400"
|
|
/>
|
|
<div className="mt-4 flex items-center justify-end gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setModalOpen(false)}
|
|
className="rounded-lg px-4 py-2 text-sm text-slate-300 hover:text-slate-50"
|
|
>
|
|
Cancelar
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleAddZone}
|
|
className="rounded-lg bg-cyan-500 px-4 py-2 text-sm font-semibold text-slate-900 hover:bg-cyan-400"
|
|
>
|
|
Adicionar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|