Merge pull request #7 from rede5/codex/create-login-and-registration-page

Add dashboard modules and update Appwrite setup guide
This commit is contained in:
Tiago Yamamoto 2025-12-11 19:04:22 -03:00 committed by GitHub
commit 63f639ffbd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 208 additions and 123 deletions

View file

@ -6,3 +6,7 @@ APPWRITE_FUNCTIONS_API_KEY=
VITE_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1 VITE_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
VITE_APPWRITE_PROJECT_ID= VITE_APPWRITE_PROJECT_ID=
VITE_APPWRITE_DATABASE_ID= VITE_APPWRITE_DATABASE_ID=
VITE_APPWRITE_COLLECTION_SERVERS_ID=
VITE_APPWRITE_COLLECTION_GITHUB_REPOS_ID=
VITE_APPWRITE_COLLECTION_AUDIT_LOGS_ID=
VITE_APPWRITE_COLLECTION_CLOUDFLARE_ACCOUNTS_ID=

View file

@ -1,70 +1,85 @@
# Core Platform Monorepo # Core Platform Monorepo
Ambiente unificado com três camadas principais: Ambiente monorepo com três camadas principais: **Landing (Fresh + Deno)**, **Dashboard (React + Vite)** e **Appwrite Cloud** como backend. Este guia prioriza a configuração do Appwrite Cloud antes de rodar qualquer código local.
- **Appwrite** para autenticação, base de dados e funções (sync-github e check-cloudflare-status). ## Passo a passo (Appwrite Cloud)
- **Landing (Fresh + Deno)** para a página pública em `landing/`. 1. Acesse https://cloud.appwrite.io e crie/abra um projeto.
- **Dashboard (React + Vite)** para o painel operacional em `dashboard/`. 2. No menu **API Keys**, gere uma chave com permissão de Admin para criar bancos, coleções e funções.
3. Registre os valores a seguir para preencher o `.env`:
## Pré-requisitos - **API Endpoint** (ex.: `https://cloud.appwrite.io/v1`)
- Node.js 18+ e npm - **Project ID**
- Deno 1.39+ (para o app Fresh) - **Database ID** (criado no passo seguinte)
- Docker e Docker Compose (para Appwrite local) ou um projeto Appwrite Cloud - Opcional: endpoints/keys das Functions se você usar domínios dedicados.
- Variáveis de ambiente configuradas a partir de [.env.example](.env.example) 4. Crie um Database chamado **DevOpsPlatform** e anote seu ID.
5. Dentro do banco, crie as coleções (IDs sugeridos entre parênteses):
- **servers** (`servers`):
- `name` (string)
- `ip` (string)
- `status` (enum: `online`, `offline`)
- `region` (string)
- **github_repos** (`github_repos`):
- `repo_name` (string)
- `url` (url)
- `last_commit` (string)
- `status` (string)
- **audit_logs** (`audit_logs`):
- `event` (string)
- `user_id` (string)
- `timestamp` (datetime)
- **cloud_accounts** (`cloud_accounts`) para integrações:
- `provider` (string)
- `apiKey` (string)
- `label` (string)
6. Ative o provedor **Email/Password** em *Authentication* e crie um usuário de teste.
7. Implante as Functions `sync-github` e `check-cloudflare-status` (fontes em `appwrite-functions/`).
## Variáveis de ambiente ## Variáveis de ambiente
- **Backend/Appwrite:** use `APPWRITE_ENDPOINT`, `APPWRITE_PROJECT_ID`, `APPWRITE_API_KEY`, `APPWRITE_DATABASE_ID` e credenciais de funções conforme necessário. Copie o arquivo de exemplo e preencha com os IDs anotados:
- **Dashboard (Vite):** use `VITE_APPWRITE_ENDPOINT`, `VITE_APPWRITE_PROJECT_ID` e `VITE_APPWRITE_DATABASE_ID`. O prefixo `VITE_` garante a leitura pelo Vite em tempo de build.
- **Landing (Deno Fresh):** o arquivo `landing/main.ts` carrega automaticamente variáveis do `.env` via `$std/dotenv/load.ts`, então os mesmos valores de Appwrite podem ser reutilizados.
Sugestão rápida:
```bash ```bash
cp .env.example .env cp .env.example .env
# Preencha IDs/projetos/chaves de acordo com seu ambiente Appwrite
``` ```
Campos principais:
- `VITE_APPWRITE_ENDPOINT`, `VITE_APPWRITE_PROJECT_ID`, `VITE_APPWRITE_DATABASE_ID`
- `VITE_APPWRITE_COLLECTION_SERVERS_ID`, `VITE_APPWRITE_COLLECTION_GITHUB_REPOS_ID`, `VITE_APPWRITE_COLLECTION_AUDIT_LOGS_ID`, `VITE_APPWRITE_COLLECTION_CLOUDFLARE_ACCOUNTS_ID`
- Para scripts server-side, use também `APPWRITE_ENDPOINT`, `APPWRITE_PROJECT_ID`, `APPWRITE_API_KEY`.
## Executando os ambientes ## Estrutura de pastas
### 1) Appwrite local - `landing/`: app Fresh (Deno) para a landing page.
1. Tenha Docker em execução. - `dashboard/`: painel React + Vite com integrações Appwrite (auth, Functions, Database, Realtime).
2. Inicie o stack Appwrite (CLI ou imagem oficial), por exemplo: - `appwrite-functions/`: funções `sync-github` e `check-cloudflare-status`.
```bash
docker run -it --rm \ ## Rodando os ambientes
1) **Appwrite local (opcional)**
```bash
docker run -it --rm \
-p 80:80 -p 443:443 \ -p 80:80 -p 443:443 \
-v "$(pwd)/appwrite:/var/lib/appwrite" \ -v "$(pwd)/appwrite:/var/lib/appwrite" \
appwrite/appwrite:latest install appwrite/appwrite:latest install
``` ```
3. Após provisionar o projeto, configure as variáveis (`APPWRITE_*` e `VITE_APPWRITE_*`) e implante as funções em `appwrite-functions/` conforme necessário. Preencha o `.env` com os IDs gerados localmente e replique o schema acima no console.
### 2) Landing (Fresh + Deno) 2) **Landing (Fresh + Deno)**
```bash ```bash
npm run dev:landing npm run dev:landing
# ou: cd landing && deno task start # ou: cd landing && deno task start
``` ```
- O servidor roda em `http://localhost:8000` por padrão. Roda em `http://localhost:8000`. O Deno carrega variáveis do `.env` automaticamente.
- As variáveis `.env` são lidas automaticamente no bootstrap do Deno.
### 3) Dashboard (React + Vite) 3) **Dashboard (React + Vite)**
```bash ```bash
cd dashboard cd dashboard
npm install npm install
npm run dev npm run dev
``` ```
- Usa os valores `VITE_APPWRITE_*` para client, database e funções. Disponível em `http://localhost:5173`. O Vite lê apenas variáveis prefixadas com `VITE_`.
- Disponível em `http://localhost:5173`.
### 4) Landing + Dashboard em paralelo 4) **Landing + Dashboard em paralelo**
No diretório raiz:
```bash ```bash
npm run dev:web npm run dev:web
``` ```
O script usa `npm-run-all` para iniciar `landing` (Deno) e `dashboard` (Vite) lado a lado. Usa `npm-run-all` para iniciar ambos os frontends.
## Estrutura de pastas ## Notas rápidas
- `appwrite-functions/`: fontes das funções `sync-github` e `check-cloudflare-status`. - Rotas do dashboard são protegidas pelo Appwrite (Email/Password) e usam `account.createEmailPasswordSession`.
- `landing/`: app Fresh (Deno) para a camada pública. - O widget de overview consulta as coleções de repositórios, servidores e audit logs para mostrar métricas reais.
- `dashboard/`: painel autenticado com React + Vite, Tailwind e integrações Appwrite. - O terminal inferior assina o canal Realtime da coleção `audit_logs` usando o ID configurado no `.env`.
## Notas finais
- O dashboard valida a sessão Appwrite no carregamento inicial e protege rotas privadas.
- O Appwrite Database precisa do ID configurado (`APPWRITE_DATABASE_ID`/`VITE_APPWRITE_DATABASE_ID`) para consultas de projetos, cloud_accounts e audit_logs.
- O canal Realtime da coleção `audit_logs` é usado para o terminal de logs em tempo real.

View file

@ -1,9 +1,5 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { client, appwriteDatabaseId } from '../lib/appwrite' import { client, appwriteCollectionAuditLogsId, appwriteDatabaseId } from '../lib/appwrite'
const channel = appwriteDatabaseId
? `databases.${appwriteDatabaseId}.collections.audit_logs.documents`
: undefined
type TerminalLog = { type TerminalLog = {
id: string id: string
@ -25,13 +21,14 @@ export function TerminalLogs() {
const logTitle = useMemo(() => (open ? 'Ocultar Terminal' : 'Mostrar Terminal'), [open]) const logTitle = useMemo(() => (open ? 'Ocultar Terminal' : 'Mostrar Terminal'), [open])
useEffect(() => { useEffect(() => {
if (!channel) return undefined if (!appwriteDatabaseId || !appwriteCollectionAuditLogsId) return undefined
const channel = `databases.${appwriteDatabaseId}.collections.${appwriteCollectionAuditLogsId}.documents`
const unsubscribe = client.subscribe(channel, (response) => { const unsubscribe = client.subscribe(channel, (response) => {
const payload = (response as { payload?: Record<string, unknown> }).payload || {} const payload = (response as { payload?: Record<string, unknown> }).payload || {}
const action = (payload.action as string) || 'evento' const action = (payload.event as string) || (payload.action as string) || 'evento'
const timestamp = (payload.timestamp as string) || new Date().toISOString() const timestamp = (payload.timestamp as string) || new Date().toISOString()
const userId = (payload.userId as string) || undefined const userId = (payload.user_id as string) || (payload.userId as string) || undefined
setLogs((prev) => [ setLogs((prev) => [
{ {
@ -47,7 +44,7 @@ export function TerminalLogs() {
return () => { return () => {
unsubscribe() unsubscribe()
} }
}, [channel]) }, [])
return ( return (
<div className="border-t border-slate-800 bg-slate-950/80 backdrop-blur"> <div className="border-t border-slate-800 bg-slate-950/80 backdrop-blur">

View file

@ -1,5 +1,5 @@
import { AppwriteException, Models } from 'appwrite' import { AppwriteException, Models } from 'appwrite'
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react' import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { Navigate, Outlet, useLocation } from 'react-router-dom' import { Navigate, Outlet, useLocation } from 'react-router-dom'
import { account } from '../lib/appwrite' import { account } from '../lib/appwrite'
@ -17,7 +17,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [user, setUser] = useState<Models.User<Models.Preferences> | null>(null) const [user, setUser] = useState<Models.User<Models.Preferences> | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const fetchUser = async () => { const fetchUser = useCallback(async () => {
try { try {
setLoading(true) setLoading(true)
const current = await account.get() const current = await account.get()
@ -32,21 +32,24 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }, [])
useEffect(() => { useEffect(() => {
fetchUser() fetchUser()
}, []) }, [fetchUser])
const login = async (email: string, password: string) => { const login = useCallback(
async (email: string, password: string) => {
await account.createEmailPasswordSession(email, password) await account.createEmailPasswordSession(email, password)
await fetchUser() await fetchUser()
} },
[fetchUser],
)
const logout = async () => { const logout = useCallback(async () => {
await account.deleteSession('current') await account.deleteSession('current')
setUser(null) setUser(null)
} }, [])
const value = useMemo( const value = useMemo(
() => ({ () => ({
@ -56,12 +59,13 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
logout, logout,
refresh: fetchUser, refresh: fetchUser,
}), }),
[loading, user], [fetchUser, loading, login, logout, user],
) )
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider> return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
} }
// eslint-disable-next-line react-refresh/only-export-components
export const useAuth = () => { export const useAuth = () => {
const context = useContext(AuthContext) const context = useContext(AuthContext)
if (!context) throw new Error('useAuth deve ser usado dentro de AuthProvider') if (!context) throw new Error('useAuth deve ser usado dentro de AuthProvider')

View file

@ -1,4 +1,4 @@
import { Cloud, Github, Home, LogOut, Settings } from 'lucide-react' import { Cloud, Github, Home, LogOut, Settings, Terminal } from 'lucide-react'
import { NavLink, Outlet, useNavigate } from 'react-router-dom' import { NavLink, Outlet, useNavigate } from 'react-router-dom'
import { TerminalLogs } from '../components/TerminalLogs' import { TerminalLogs } from '../components/TerminalLogs'
import { useAuth } from '../contexts/Auth' import { useAuth } from '../contexts/Auth'
@ -11,9 +11,9 @@ const navItems = [
] ]
const activeClass = const activeClass =
'flex items-center gap-2 rounded-lg bg-slate-800/60 px-3 py-2 text-slate-50 shadow-inner shadow-slate-900' 'flex items-center gap-2 rounded-md bg-slate-800/80 px-3 py-2 text-slate-50 shadow-inner shadow-slate-950 border border-slate-700'
const baseClass = const baseClass =
'flex items-center gap-2 rounded-lg px-3 py-2 text-slate-300 hover:bg-slate-800/40 transition-colors duration-150' 'flex items-center gap-2 rounded-md px-3 py-2 text-slate-300 hover:bg-slate-800/50 transition-colors duration-150 border border-transparent'
export default function DashboardLayout() { export default function DashboardLayout() {
const { user, logout } = useAuth() const { user, logout } = useAuth()
@ -26,14 +26,14 @@ export default function DashboardLayout() {
return ( return (
<div className="flex min-h-screen bg-slate-950 text-slate-100"> <div className="flex min-h-screen bg-slate-950 text-slate-100">
<aside className="fixed left-0 top-0 flex h-full w-64 flex-col border-r border-slate-800 bg-slate-900/80 px-4 py-6 shadow-2xl shadow-slate-900/80"> <aside className="fixed left-0 top-0 flex h-full w-64 flex-col border-r border-slate-800 bg-slate-900/90 px-4 py-4 shadow-2xl shadow-slate-950/80">
<div className="mb-6 border-b border-slate-800 pb-4"> <div className="mb-4 border-b border-slate-800 pb-3">
<p className="text-xs uppercase tracking-[0.3em] text-cyan-400">Fase 3</p> <p className="text-[10px] uppercase tracking-[0.35em] text-cyan-400">DevOps</p>
<h1 className="text-lg font-semibold text-slate-50">Dashboard</h1> <h1 className="text-lg font-semibold text-slate-50">Dashboard</h1>
<p className="text-xs text-slate-400">Automação e DevOps</p> <p className="text-xs text-slate-400">VSCode-like dense sidebar</p>
</div> </div>
<nav className="flex-1 space-y-2"> <nav className="flex-1 space-y-1 text-sm">
{navItems.map((item) => ( {navItems.map((item) => (
<NavLink <NavLink
key={item.to} key={item.to}
@ -41,33 +41,37 @@ export default function DashboardLayout() {
end={item.to === '/'} end={item.to === '/'}
className={({ isActive }) => (isActive ? activeClass : baseClass)} className={({ isActive }) => (isActive ? activeClass : baseClass)}
> >
<item.icon size={18} /> <item.icon size={16} />
<span className="text-sm">{item.label}</span> <span className="text-sm">{item.label}</span>
</NavLink> </NavLink>
))} ))}
</nav> </nav>
<div className="mt-auto space-y-2 border-t border-slate-800 pt-4 text-sm text-slate-300"> <div className="mt-auto space-y-2 border-t border-slate-800 pt-3 text-sm text-slate-300">
<p className="truncate text-xs text-slate-500">{user?.email}</p> <p className="flex items-center justify-between text-xs text-slate-500">
<span className="truncate">{user?.email}</span>
<Terminal size={14} className="text-cyan-300" />
</p>
<button <button
type="button" type="button"
onClick={handleLogout} onClick={handleLogout}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-slate-300 transition hover:bg-red-500/10 hover:text-red-200" className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-slate-300 transition hover:bg-red-500/10 hover:text-red-200"
> >
<LogOut size={18} /> <LogOut size={16} />
Sair Sair
</button> </button>
</div> </div>
</aside> </aside>
<div className="ml-64 flex min-h-screen flex-1 flex-col"> <div className="ml-64 flex min-h-screen flex-1 flex-col">
<header className="flex items-center justify-between border-b border-slate-800 bg-slate-900/60 px-6 py-4 backdrop-blur"> <header className="flex items-center justify-between border-b border-slate-800 bg-slate-900/70 px-6 py-3 backdrop-blur">
<div> <div>
<p className="text-xs uppercase tracking-[0.2em] text-cyan-300">Painel de Controle</p> <p className="text-[10px] uppercase tracking-[0.28em] text-cyan-300">Painel de Controle</p>
<h2 className="text-xl font-semibold text-slate-50">DevOps Orchestration</h2> <h2 className="text-xl font-semibold text-slate-50">DevOps Orchestration</h2>
</div> </div>
<div className="rounded-lg bg-slate-800/60 px-4 py-2 text-xs text-slate-300 shadow-inner shadow-slate-900"> <div className="flex items-center gap-2 rounded-lg bg-slate-800/70 px-3 py-1 text-xs text-slate-300 shadow-inner shadow-slate-950">
Sessão ativa: <span className="text-cyan-300">{user?.name || user?.email}</span> <span className="rounded bg-emerald-500/20 px-2 py-0.5 text-emerald-300">Online</span>
<span className="text-cyan-200">{user?.name || user?.email}</span>
</div> </div>
</header> </header>

View file

@ -3,6 +3,10 @@ import { Account, AppwriteException, Client, Databases, Functions, Models } from
const appwriteEndpoint = import.meta.env.VITE_APPWRITE_ENDPOINT const appwriteEndpoint = import.meta.env.VITE_APPWRITE_ENDPOINT
const appwriteProjectId = import.meta.env.VITE_APPWRITE_PROJECT_ID const appwriteProjectId = import.meta.env.VITE_APPWRITE_PROJECT_ID
const appwriteDatabaseId = import.meta.env.VITE_APPWRITE_DATABASE_ID const appwriteDatabaseId = import.meta.env.VITE_APPWRITE_DATABASE_ID
const appwriteCollectionServersId = import.meta.env.VITE_APPWRITE_COLLECTION_SERVERS_ID
const appwriteCollectionGithubReposId = import.meta.env.VITE_APPWRITE_COLLECTION_GITHUB_REPOS_ID
const appwriteCollectionAuditLogsId = import.meta.env.VITE_APPWRITE_COLLECTION_AUDIT_LOGS_ID
const appwriteCollectionCloudflareAccountsId = import.meta.env.VITE_APPWRITE_COLLECTION_CLOUDFLARE_ACCOUNTS_ID
if (!appwriteEndpoint || !appwriteProjectId) { if (!appwriteEndpoint || !appwriteProjectId) {
throw new Error('Defina VITE_APPWRITE_ENDPOINT e VITE_APPWRITE_PROJECT_ID no ambiente do Vite.') throw new Error('Defina VITE_APPWRITE_ENDPOINT e VITE_APPWRITE_PROJECT_ID no ambiente do Vite.')
@ -49,4 +53,14 @@ export const getCurrentUser = async (): Promise<Models.User<Models.Preferences>
} }
} }
export { account, client, databases, functions, appwriteDatabaseId } export {
account,
client,
databases,
functions,
appwriteDatabaseId,
appwriteCollectionServersId,
appwriteCollectionGithubReposId,
appwriteCollectionAuditLogsId,
appwriteCollectionCloudflareAccountsId,
}

View file

@ -1,6 +1,12 @@
import { Execution, ID, Query } from 'appwrite' import { Execution, ID, Query } from 'appwrite'
import { FormEvent, useEffect, useState } from 'react' import { FormEvent, useEffect, useState } from 'react'
import { appwriteDatabaseId, databases, functions } from '../lib/appwrite' import {
appwriteCollectionCloudflareAccountsId,
appwriteDatabaseId,
appwriteCollectionServersId,
databases,
functions,
} from '../lib/appwrite'
const badgeClass = (status: string) => 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' 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'
@ -30,12 +36,13 @@ export default function Cloudflare() {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [modalOpen, setModalOpen] = useState(false) const [modalOpen, setModalOpen] = useState(false)
const [newZone, setNewZone] = useState('') const [newZone, setNewZone] = useState('')
const [onlineCount, setOnlineCount] = useState<number | null>(null)
useEffect(() => { useEffect(() => {
const loadCredentials = async () => { const loadCredentials = async () => {
if (!appwriteDatabaseId) return if (!appwriteDatabaseId || !appwriteCollectionCloudflareAccountsId) return
try { try {
const response = await databases.listDocuments(appwriteDatabaseId, 'cloud_accounts', [ const response = await databases.listDocuments(appwriteDatabaseId, appwriteCollectionCloudflareAccountsId, [
Query.equal('provider', 'cloudflare'), Query.equal('provider', 'cloudflare'),
]) ])
const docs = response.documents.map((doc) => ({ $id: doc.$id, label: (doc as { label?: string }).label || doc.$id })) const docs = response.documents.map((doc) => ({ $id: doc.$id, label: (doc as { label?: string }).label || doc.$id }))
@ -46,19 +53,32 @@ export default function 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() loadCredentials()
loadWorkers()
}, []) }, [])
const handleSaveKey = async (event: FormEvent) => { const handleSaveKey = async (event: FormEvent) => {
event.preventDefault() event.preventDefault()
setError(null) setError(null)
if (!appwriteDatabaseId) { if (!appwriteDatabaseId || !appwriteCollectionCloudflareAccountsId) {
setError('Configure VITE_APPWRITE_DATABASE_ID para salvar a chave de API.') setError('Configure VITE_APPWRITE_DATABASE_ID e VITE_APPWRITE_COLLECTION_CLOUDFLARE_ACCOUNTS_ID para salvar a chave de API.')
return return
} }
try { try {
const document = await databases.createDocument(appwriteDatabaseId, 'cloud_accounts', ID.unique(), { const document = await databases.createDocument(appwriteDatabaseId, appwriteCollectionCloudflareAccountsId, ID.unique(), {
provider: 'cloudflare', provider: 'cloudflare',
apiKey, apiKey,
label, label,
@ -228,7 +248,10 @@ export default function Cloudflare() {
</div> </div>
<div className="rounded-xl border border-slate-800/70 bg-slate-900/70 p-5"> <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> <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"> <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.length === 0 ? <li className="py-3 text-slate-500">Nenhum worker listado.</li> : null}
{workers.map((worker) => ( {workers.map((worker) => (

View file

@ -28,7 +28,7 @@ export default function Github() {
try { try {
const executionResult = await functions.createExecution( const executionResult = await functions.createExecution(
'sync-github', 'sync-github',
JSON.stringify({ accountId }), accountId ? JSON.stringify({ accountId }) : undefined,
) )
setExecution(executionResult) setExecution(executionResult)
@ -49,7 +49,7 @@ export default function Github() {
<p className="text-xs uppercase tracking-[0.3em] text-cyan-300">GitHub</p> <p className="text-xs uppercase tracking-[0.3em] text-cyan-300">GitHub</p>
<h1 className="mt-2 text-2xl font-semibold text-slate-50">Sincronizar Repositórios</h1> <h1 className="mt-2 text-2xl font-semibold text-slate-50">Sincronizar Repositórios</h1>
<p className="mt-2 text-sm text-slate-400"> <p className="mt-2 text-sm text-slate-400">
Execute a função <span className="text-cyan-300">sync-github</span> e visualize o catálogo retornado pelo Appwrite. Execute a função <span className="text-cyan-300">sync-github</span> via Appwrite Functions e visualize o catálogo retornado.
</p> </p>
<div className="mt-4 flex flex-col gap-3 sm:flex-row"> <div className="mt-4 flex flex-col gap-3 sm:flex-row">
@ -57,13 +57,13 @@ export default function Github() {
type="text" type="text"
value={accountId} value={accountId}
onChange={(e) => setAccountId(e.target.value)} onChange={(e) => setAccountId(e.target.value)}
placeholder="ID do documento em cloud_accounts" placeholder="ID do documento em cloud_accounts (opcional)"
className="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" className="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"
/> />
<button <button
type="button" type="button"
onClick={handleSync} onClick={handleSync}
disabled={loading || !accountId} disabled={loading}
className="rounded-lg bg-cyan-500 px-4 py-2 text-sm font-semibold text-slate-900 transition hover:bg-cyan-400 disabled:cursor-not-allowed disabled:opacity-50" className="rounded-lg bg-cyan-500 px-4 py-2 text-sm font-semibold text-slate-900 transition hover:bg-cyan-400 disabled:cursor-not-allowed disabled:opacity-50"
> >
{loading ? 'Sincronizando...' : 'Executar função'} {loading ? 'Sincronizando...' : 'Executar função'}
@ -92,7 +92,7 @@ export default function Github() {
{repos.length === 0 ? ( {repos.length === 0 ? (
<tr> <tr>
<td colSpan={4} className="px-4 py-6 text-center text-slate-500"> <td colSpan={4} className="px-4 py-6 text-center text-slate-500">
Nenhum repositório sincronizado ainda. Nenhum repositório sincronizado ainda. Execute a função acima para preencher a lista.
</td> </td>
</tr> </tr>
) : ( ) : (

View file

@ -1,6 +1,12 @@
import { Query } from 'appwrite' import { Query } from 'appwrite'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { appwriteDatabaseId, databases } from '../lib/appwrite' import {
appwriteCollectionAuditLogsId,
appwriteCollectionGithubReposId,
appwriteCollectionServersId,
appwriteDatabaseId,
databases,
} from '../lib/appwrite'
const statCard = (label: string, value: string | number, helper?: string) => ( const statCard = (label: string, value: string | number, helper?: string) => (
<div className="rounded-xl border border-slate-800/70 bg-slate-900/70 p-4 shadow-inner shadow-slate-950/60"> <div className="rounded-xl border border-slate-800/70 bg-slate-900/70 p-4 shadow-inner shadow-slate-950/60">
@ -10,6 +16,12 @@ const statCard = (label: string, value: string | number, helper?: string) => (
</div> </div>
) )
type DeploymentLog = { timestamp?: string }
type Server = { status?: string }
type Repo = { $id: string }
export default function Home() { export default function Home() {
const [projectsTotal, setProjectsTotal] = useState<number | null>(null) const [projectsTotal, setProjectsTotal] = useState<number | null>(null)
const [activeWorkers, setActiveWorkers] = useState<number | null>(null) const [activeWorkers, setActiveWorkers] = useState<number | null>(null)
@ -17,22 +29,29 @@ export default function Home() {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
const fetchMetrics = async () => { const missing = [appwriteDatabaseId, appwriteCollectionGithubReposId, appwriteCollectionServersId, appwriteCollectionAuditLogsId]
if (!appwriteDatabaseId) { .filter((value) => !value)
setError('Defina VITE_APPWRITE_DATABASE_ID para consultar métricas.') if (missing.length > 0) {
setError('Configure VITE_APPWRITE_DATABASE_ID e as collections de repositórios, servidores e audit logs para carregar a visão geral.')
return return
} }
const fetchMetrics = async () => {
try { try {
const [projects, cloudAccounts, deployments] = await Promise.all([ const [repos, servers, deployments] = await Promise.all([
databases.listDocuments(appwriteDatabaseId, 'projects', [Query.limit(1)]), databases.listDocuments<Repo>(appwriteDatabaseId!, appwriteCollectionGithubReposId!, [Query.limit(1)]),
databases.listDocuments(appwriteDatabaseId, 'cloud_accounts', [Query.limit(1)]), databases.listDocuments<Server>(appwriteDatabaseId!, appwriteCollectionServersId!, [Query.limit(200)]),
databases.listDocuments(appwriteDatabaseId, 'audit_logs', [Query.orderDesc('timestamp'), Query.limit(1)]), databases.listDocuments<DeploymentLog>(appwriteDatabaseId!, appwriteCollectionAuditLogsId!, [
Query.orderDesc('timestamp'),
Query.limit(1),
]),
]) ])
setProjectsTotal(projects.total) setProjectsTotal(repos.total)
setActiveWorkers(cloudAccounts.total) const onlineWorkers = servers.documents.filter((server) => server.status === 'online').length
const lastTimestamp = deployments.documents[0]?.timestamp as string | undefined setActiveWorkers(onlineWorkers)
const lastTimestamp = deployments.documents[0]?.timestamp
setLastDeployment(lastTimestamp ? new Date(lastTimestamp).toLocaleString() : 'Sem deploys registrados') setLastDeployment(lastTimestamp ? new Date(lastTimestamp).toLocaleString() : 'Sem deploys registrados')
} catch (err) { } catch (err) {
console.error(err) console.error(err)
@ -57,7 +76,7 @@ export default function Home() {
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{statCard('Total Repos', projectsTotal ?? '---', 'Quantidade de projetos cadastrados no Appwrite')} {statCard('Total Repos', projectsTotal ?? '---', 'Quantidade de projetos cadastrados no Appwrite')}
{statCard('Active Workers', activeWorkers ?? '---', 'Workers monitorados pelo Cloudflare')} {statCard('Active Workers', activeWorkers ?? '---', 'Workers com status online no banco de servidores')}
{statCard('Last Deployment', lastDeployment)} {statCard('Last Deployment', lastDeployment)}
</div> </div>
@ -85,8 +104,8 @@ export default function Home() {
<span className="text-xs text-cyan-300">{appwriteDatabaseId ? 'Configurado' : 'Pendente'}</span> <span className="text-xs text-cyan-300">{appwriteDatabaseId ? 'Configurado' : 'Pendente'}</span>
</div> </div>
<div className="flex items-center justify-between rounded-lg border border-slate-800/70 bg-slate-950/60 px-3 py-2"> <div className="flex items-center justify-between rounded-lg border border-slate-800/70 bg-slate-950/60 px-3 py-2">
<span>Cloud Accounts</span> <span>GitHub Repos</span>
<span className="text-xs text-cyan-300">{activeWorkers ?? '---'} credenciais</span> <span className="text-xs text-cyan-300">{projectsTotal ?? '---'} cadastrados</span>
</div> </div>
<div className="flex items-center justify-between rounded-lg border border-slate-800/70 bg-slate-950/60 px-3 py-2"> <div className="flex items-center justify-between rounded-lg border border-slate-800/70 bg-slate-950/60 px-3 py-2">
<span>Realtime Logs</span> <span>Realtime Logs</span>

View file

@ -1,3 +1,4 @@
import { AppwriteException } from 'appwrite'
import { FormEvent, useState } from 'react' import { FormEvent, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/Auth' import { useAuth } from '../contexts/Auth'
@ -21,7 +22,8 @@ export default function Login() {
const redirectTo = (location.state as { from?: { pathname?: string } })?.from?.pathname || '/' const redirectTo = (location.state as { from?: { pathname?: string } })?.from?.pathname || '/'
navigate(redirectTo, { replace: true }) navigate(redirectTo, { replace: true })
} catch (err) { } catch (err) {
setError('Falha ao autenticar. Confira as credenciais e tente novamente.') const appwriteError = err as AppwriteException
setError(appwriteError.message || 'Falha ao autenticar. Confira as credenciais e tente novamente.')
} finally { } finally {
setLoading(false) setLoading(false)
} }
@ -33,42 +35,45 @@ export default function Login() {
className="pointer-events-none absolute inset-0 opacity-60" className="pointer-events-none absolute inset-0 opacity-60"
style={{ style={{
backgroundImage: backgroundImage:
'linear-gradient(rgba(59,130,246,0.08) 1px, transparent 1px), linear-gradient(90deg, rgba(14,165,233,0.08) 1px, transparent 1px)', 'linear-gradient(rgba(56,189,248,0.05) 1px, transparent 1px), linear-gradient(90deg, rgba(52,211,153,0.05) 1px, transparent 1px)',
backgroundSize: '28px 28px', backgroundSize: '32px 32px',
backgroundPosition: 'center', backgroundPosition: 'center',
}} }}
/> />
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(14,165,233,0.08),transparent_40%)]" />
<div className="relative z-10 flex min-h-screen items-center justify-center px-4"> <div className="relative z-10 flex min-h-screen items-center justify-center px-4">
<div className="w-full max-w-md rounded-2xl border border-slate-800/80 bg-slate-900/80 p-8 shadow-2xl shadow-cyan-900/40 backdrop-blur"> <div className="w-full max-w-lg rounded-2xl border border-cyan-500/30 bg-slate-900/90 p-10 shadow-2xl shadow-cyan-900/50 backdrop-blur-lg">
<p className="text-xs uppercase tracking-[0.3em] text-cyan-300">Appwrite</p> <p className="text-xs uppercase tracking-[0.35em] text-cyan-300">Appwrite</p>
<h1 className="mt-2 text-2xl font-semibold text-slate-50">Acesse o Dashboard</h1> <h1 className="mt-2 text-3xl font-semibold text-slate-50">Acesse o Dashboard</h1>
<p className="mt-2 text-sm text-slate-400">Autentique-se para visualizar repositórios, zonas Cloudflare e logs.</p> <p className="mt-2 text-sm text-slate-400">
Caixa flutuante estilo blueprint. Autentique-se para visualizar repositórios, zonas Cloudflare e logs.
</p>
<form className="mt-6 space-y-4" onSubmit={handleSubmit}> <form className="mt-6 space-y-4" onSubmit={handleSubmit}>
<div> <div className="grid gap-2">
<label className="text-sm text-slate-200">E-mail</label> <label className="text-sm text-slate-200">E-mail</label>
<input <input
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required 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" className="w-full rounded-lg border border-cyan-500/30 bg-slate-950/60 px-3 py-2 text-sm text-slate-100 outline-none focus:border-cyan-400 focus:ring-1 focus:ring-cyan-400"
/> />
</div> </div>
<div> <div className="grid gap-2">
<label className="text-sm text-slate-200">Senha</label> <label className="text-sm text-slate-200">Senha</label>
<input <input
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required 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" className="w-full rounded-lg border border-cyan-500/30 bg-slate-950/60 px-3 py-2 text-sm text-slate-100 outline-none focus:border-cyan-400 focus:ring-1 focus:ring-cyan-400"
/> />
</div> </div>
{error ? <p className="text-sm text-red-300">{error}</p> : null} {error ? <p className="rounded-lg border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">{error}</p> : null}
<button <button
type="submit" type="submit"