Add dashboard auth layout and integrations
This commit is contained in:
parent
71e495db2f
commit
e239d63d2f
15 changed files with 3026 additions and 32 deletions
|
|
@ -3,3 +3,6 @@ APPWRITE_PROJECT_ID=
|
||||||
APPWRITE_API_KEY=
|
APPWRITE_API_KEY=
|
||||||
APPWRITE_FUNCTIONS_ENDPOINT=
|
APPWRITE_FUNCTIONS_ENDPOINT=
|
||||||
APPWRITE_FUNCTIONS_API_KEY=
|
APPWRITE_FUNCTIONS_API_KEY=
|
||||||
|
VITE_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
|
||||||
|
VITE_APPWRITE_PROJECT_ID=
|
||||||
|
VITE_APPWRITE_DATABASE_ID=
|
||||||
|
|
|
||||||
71
README.md
71
README.md
|
|
@ -1 +1,70 @@
|
||||||
# core
|
# Core Platform Monorepo
|
||||||
|
|
||||||
|
Ambiente unificado com três camadas principais:
|
||||||
|
|
||||||
|
- **Appwrite** para autenticação, base de dados e funções (sync-github e check-cloudflare-status).
|
||||||
|
- **Landing (Fresh + Deno)** para a página pública em `landing/`.
|
||||||
|
- **Dashboard (React + Vite)** para o painel operacional em `dashboard/`.
|
||||||
|
|
||||||
|
## Pré-requisitos
|
||||||
|
- Node.js 18+ e npm
|
||||||
|
- Deno 1.39+ (para o app Fresh)
|
||||||
|
- Docker e Docker Compose (para Appwrite local) ou um projeto Appwrite Cloud
|
||||||
|
- Variáveis de ambiente configuradas a partir de [.env.example](.env.example)
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- **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
|
||||||
|
cp .env.example .env
|
||||||
|
# Preencha IDs/projetos/chaves de acordo com seu ambiente Appwrite
|
||||||
|
```
|
||||||
|
|
||||||
|
## Executando os ambientes
|
||||||
|
### 1) Appwrite local
|
||||||
|
1. Tenha Docker em execução.
|
||||||
|
2. Inicie o stack Appwrite (CLI ou imagem oficial), por exemplo:
|
||||||
|
```bash
|
||||||
|
docker run -it --rm \
|
||||||
|
-p 80:80 -p 443:443 \
|
||||||
|
-v "$(pwd)/appwrite:/var/lib/appwrite" \
|
||||||
|
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.
|
||||||
|
|
||||||
|
### 2) Landing (Fresh + Deno)
|
||||||
|
```bash
|
||||||
|
npm run dev:landing
|
||||||
|
# ou: cd landing && deno task start
|
||||||
|
```
|
||||||
|
- O servidor roda em `http://localhost:8000` por padrão.
|
||||||
|
- As variáveis `.env` são lidas automaticamente no bootstrap do Deno.
|
||||||
|
|
||||||
|
### 3) Dashboard (React + Vite)
|
||||||
|
```bash
|
||||||
|
cd dashboard
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
- Usa os valores `VITE_APPWRITE_*` para client, database e funções.
|
||||||
|
- Disponível em `http://localhost:5173`.
|
||||||
|
|
||||||
|
### 4) Landing + Dashboard em paralelo
|
||||||
|
No diretório raiz:
|
||||||
|
```bash
|
||||||
|
npm run dev:web
|
||||||
|
```
|
||||||
|
O script usa `npm-run-all` para iniciar `landing` (Deno) e `dashboard` (Vite) lado a lado.
|
||||||
|
|
||||||
|
## Estrutura de pastas
|
||||||
|
- `appwrite-functions/`: fontes das funções `sync-github` e `check-cloudflare-status`.
|
||||||
|
- `landing/`: app Fresh (Deno) para a camada pública.
|
||||||
|
- `dashboard/`: painel autenticado com React + Vite, Tailwind e integrações Appwrite.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,30 @@
|
||||||
|
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||||
|
import DashboardLayout from './layouts/DashboardLayout'
|
||||||
|
import { PrivateRoute } from './contexts/Auth'
|
||||||
|
import Cloudflare from './pages/Cloudflare'
|
||||||
|
import Github from './pages/Github'
|
||||||
|
import Home from './pages/Home'
|
||||||
|
import Login from './pages/Login'
|
||||||
|
import Settings from './pages/Settings'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen items-center justify-center p-6">
|
<BrowserRouter>
|
||||||
<div className="w-full max-w-2xl rounded-2xl border border-slate-800/70 bg-slate-900/60 p-8 shadow-xl shadow-slate-900/60 backdrop-blur">
|
<Routes>
|
||||||
<p className="text-xs uppercase tracking-[0.3em] text-cyan-300">Fase 3 • Dashboard</p>
|
<Route path="/login" element={<Login />} />
|
||||||
<h1 className="mt-3 text-3xl font-semibold text-slate-50">React + Vite + Appwrite</h1>
|
|
||||||
<p className="mt-3 text-sm leading-relaxed text-slate-300">
|
|
||||||
Projeto inicializado com Vite (React + TypeScript), Tailwind CSS e dependências do SDK web do Appwrite. Os serviços de
|
|
||||||
autenticação estão encapsulados em helpers reutilizáveis para login, logout e leitura segura da sessão atual.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
<Route element={<PrivateRoute />}>
|
||||||
<div className="rounded-xl border border-slate-800/60 bg-slate-900/80 p-4">
|
<Route element={<DashboardLayout />}>
|
||||||
<h2 className="text-sm font-semibold text-slate-100">Stack pronta para UI</h2>
|
<Route index element={<Home />} />
|
||||||
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm text-slate-300">
|
<Route path="/github" element={<Github />} />
|
||||||
<li>Tailwind configurado para <code className="rounded bg-slate-800 px-1">src</code> e <code className="rounded bg-slate-800 px-1">index.html</code>.</li>
|
<Route path="/cloudflare" element={<Cloudflare />} />
|
||||||
<li>Dependências instaladas: Appwrite SDK, React Router, Lucide Icons.</li>
|
<Route path="/settings" element={<Settings />} />
|
||||||
<li>Estilos base prontos para páginas do dashboard.</li>
|
</Route>
|
||||||
</ul>
|
</Route>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-slate-800/60 bg-slate-900/80 p-4">
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
<h2 className="text-sm font-semibold text-slate-100">Appwrite pronto</h2>
|
</Routes>
|
||||||
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm text-slate-300">
|
</BrowserRouter>
|
||||||
<li>Client, Account e Databases inicializados em <code className="rounded bg-slate-800 px-1">src/lib/appwrite.ts</code>.</li>
|
|
||||||
<li>Helpers para login, logout e recuperação da sessão atual.</li>
|
|
||||||
<li>Tratamento de sessão expirada retornando <span className="font-semibold text-cyan-300">null</span> quando apropriado.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
82
dashboard/src/components/TerminalLogs.tsx
Normal file
82
dashboard/src/components/TerminalLogs.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { client, appwriteDatabaseId } from '../lib/appwrite'
|
||||||
|
|
||||||
|
const channel = appwriteDatabaseId
|
||||||
|
? `databases.${appwriteDatabaseId}.collections.audit_logs.documents`
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
type TerminalLog = {
|
||||||
|
id: string
|
||||||
|
message: string
|
||||||
|
timestamp: string
|
||||||
|
userId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (timestamp: string) => {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
if (Number.isNaN(date.getTime())) return timestamp
|
||||||
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TerminalLogs() {
|
||||||
|
const [open, setOpen] = useState(true)
|
||||||
|
const [logs, setLogs] = useState<TerminalLog[]>([])
|
||||||
|
|
||||||
|
const logTitle = useMemo(() => (open ? 'Ocultar Terminal' : 'Mostrar Terminal'), [open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!channel) return undefined
|
||||||
|
|
||||||
|
const unsubscribe = client.subscribe(channel, (response) => {
|
||||||
|
const payload = (response as { payload?: Record<string, unknown> }).payload || {}
|
||||||
|
const action = (payload.action as string) || 'evento'
|
||||||
|
const timestamp = (payload.timestamp as string) || new Date().toISOString()
|
||||||
|
const userId = (payload.userId as string) || undefined
|
||||||
|
|
||||||
|
setLogs((prev) => [
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
message: action,
|
||||||
|
timestamp,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
...prev,
|
||||||
|
].slice(0, 120))
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe()
|
||||||
|
}
|
||||||
|
}, [channel])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t border-slate-800 bg-slate-950/80 backdrop-blur">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((prev) => !prev)}
|
||||||
|
className="flex w-full items-center justify-between px-4 py-2 text-left text-xs uppercase tracking-[0.2em] text-cyan-200"
|
||||||
|
>
|
||||||
|
<span>{logTitle}</span>
|
||||||
|
<span className="text-[10px] font-mono text-slate-400">audit_logs realtime</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="max-h-56 overflow-y-auto bg-black/60 font-mono text-[12px] text-emerald-200">
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<p className="px-4 py-3 text-slate-400">Aguardando logs em tempo real...</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-slate-800/70">
|
||||||
|
{logs.map((log) => (
|
||||||
|
<li key={log.id} className="px-4 py-2">
|
||||||
|
<span className="text-slate-500">[{formatTime(log.timestamp)}]</span>{' '}
|
||||||
|
<span className="text-emerald-300">{log.message}</span>
|
||||||
|
{log.userId ? <span className="text-slate-500"> • {log.userId}</span> : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
88
dashboard/src/contexts/Auth.tsx
Normal file
88
dashboard/src/contexts/Auth.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { AppwriteException, Models } from 'appwrite'
|
||||||
|
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Navigate, Outlet, useLocation } from 'react-router-dom'
|
||||||
|
import { account } from '../lib/appwrite'
|
||||||
|
|
||||||
|
type AuthContextValue = {
|
||||||
|
user: Models.User<Models.Preferences> | null
|
||||||
|
loading: boolean
|
||||||
|
login: (email: string, password: string) => Promise<void>
|
||||||
|
logout: () => Promise<void>
|
||||||
|
refresh: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | undefined>(undefined)
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [user, setUser] = useState<Models.User<Models.Preferences> | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const fetchUser = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const current = await account.get()
|
||||||
|
setUser(current)
|
||||||
|
} catch (error) {
|
||||||
|
const authError = error as AppwriteException
|
||||||
|
if (authError?.code === 401) {
|
||||||
|
setUser(null)
|
||||||
|
} else {
|
||||||
|
console.error('Erro ao buscar usuário atual', error)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUser()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const login = async (email: string, password: string) => {
|
||||||
|
await account.createEmailPasswordSession(email, password)
|
||||||
|
await fetchUser()
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
await account.deleteSession('current')
|
||||||
|
setUser(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
refresh: fetchUser,
|
||||||
|
}),
|
||||||
|
[loading, user],
|
||||||
|
)
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext)
|
||||||
|
if (!context) throw new Error('useAuth deve ser usado dentro de AuthProvider')
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PrivateRoute: React.FC = () => {
|
||||||
|
const { user, loading } = useAuth()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-screen place-items-center bg-slate-950 text-slate-100">
|
||||||
|
<p className="animate-pulse text-sm text-slate-400">Validando sessão...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <Navigate to="/login" state={{ from: location }} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Outlet />
|
||||||
|
}
|
||||||
82
dashboard/src/layouts/DashboardLayout.tsx
Normal file
82
dashboard/src/layouts/DashboardLayout.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { Cloud, Github, Home, LogOut, Settings } from 'lucide-react'
|
||||||
|
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
|
||||||
|
import { TerminalLogs } from '../components/TerminalLogs'
|
||||||
|
import { useAuth } from '../contexts/Auth'
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ label: 'Overview', to: '/', icon: Home },
|
||||||
|
{ label: 'GitHub Repos', to: '/github', icon: Github },
|
||||||
|
{ label: 'Cloudflare Zones', to: '/cloudflare', icon: Cloud },
|
||||||
|
{ label: 'Settings', to: '/settings', icon: Settings },
|
||||||
|
]
|
||||||
|
|
||||||
|
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'
|
||||||
|
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'
|
||||||
|
|
||||||
|
export default function DashboardLayout() {
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout()
|
||||||
|
navigate('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<div className="mb-6 border-b border-slate-800 pb-4">
|
||||||
|
<p className="text-xs uppercase tracking-[0.3em] text-cyan-400">Fase 3</p>
|
||||||
|
<h1 className="text-lg font-semibold text-slate-50">Dashboard</h1>
|
||||||
|
<p className="text-xs text-slate-400">Automação e DevOps</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 space-y-2">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end={item.to === '/'}
|
||||||
|
className={({ isActive }) => (isActive ? activeClass : baseClass)}
|
||||||
|
>
|
||||||
|
<item.icon size={18} />
|
||||||
|
<span className="text-sm">{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="mt-auto space-y-2 border-t border-slate-800 pt-4 text-sm text-slate-300">
|
||||||
|
<p className="truncate text-xs text-slate-500">{user?.email}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<LogOut size={18} />
|
||||||
|
Sair
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-cyan-300">Painel de Controle</p>
|
||||||
|
<h2 className="text-xl font-semibold text-slate-50">DevOps Orchestration</h2>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-slate-800/60 px-4 py-2 text-xs text-slate-300 shadow-inner shadow-slate-900">
|
||||||
|
Sessão ativa: <span className="text-cyan-300">{user?.name || user?.email}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1 overflow-y-auto bg-slate-900/40 px-8 py-6">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<TerminalLogs />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { Account, AppwriteException, Client, Databases, Models } from 'appwrite'
|
import { Account, AppwriteException, Client, Databases, Functions, Models } from 'appwrite'
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
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.')
|
||||||
|
|
@ -11,6 +12,7 @@ const client = new Client().setEndpoint(appwriteEndpoint).setProject(appwritePro
|
||||||
|
|
||||||
const account = new Account(client)
|
const account = new Account(client)
|
||||||
const databases = new Databases(client)
|
const databases = new Databases(client)
|
||||||
|
const functions = new Functions(client)
|
||||||
|
|
||||||
const isSessionExpired = (error: unknown) => {
|
const isSessionExpired = (error: unknown) => {
|
||||||
if (error instanceof AppwriteException) {
|
if (error instanceof AppwriteException) {
|
||||||
|
|
@ -47,4 +49,4 @@ export const getCurrentUser = async (): Promise<Models.User<Models.Preferences>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { account, client, databases }
|
export { account, client, databases, functions, appwriteDatabaseId }
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
import { AuthProvider } from './contexts/Auth.tsx'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<AuthProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
282
dashboard/src/pages/Cloudflare.tsx
Normal file
282
dashboard/src/pages/Cloudflare.tsx
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
import { Execution, ID, Query } from 'appwrite'
|
||||||
|
import { FormEvent, useEffect, useState } from 'react'
|
||||||
|
import { appwriteDatabaseId, 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('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadCredentials = async () => {
|
||||||
|
if (!appwriteDatabaseId) return
|
||||||
|
try {
|
||||||
|
const response = await databases.listDocuments(appwriteDatabaseId, 'cloud_accounts', [
|
||||||
|
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.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadCredentials()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSaveKey = async (event: FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
if (!appwriteDatabaseId) {
|
||||||
|
setError('Configure VITE_APPWRITE_DATABASE_ID para salvar a chave de API.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const document = await databases.createDocument(appwriteDatabaseId, 'cloud_accounts', 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">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-100">Workers</h3>
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
118
dashboard/src/pages/Github.tsx
Normal file
118
dashboard/src/pages/Github.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
import { Execution } from 'appwrite'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { functions } from '../lib/appwrite'
|
||||||
|
|
||||||
|
const columns = ['Nome', 'Linguagem', 'Stars', 'Último Commit']
|
||||||
|
|
||||||
|
type Repo = {
|
||||||
|
id: string | number
|
||||||
|
name: string
|
||||||
|
language?: string | null
|
||||||
|
stargazers_count?: number
|
||||||
|
stars?: number
|
||||||
|
pushed_at?: string
|
||||||
|
last_commit?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Github() {
|
||||||
|
const [accountId, setAccountId] = useState('')
|
||||||
|
const [execution, setExecution] = useState<Execution | null>(null)
|
||||||
|
const [repos, setRepos] = useState<Repo[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleSync = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const executionResult = await functions.createExecution(
|
||||||
|
'sync-github',
|
||||||
|
JSON.stringify({ accountId }),
|
||||||
|
)
|
||||||
|
setExecution(executionResult)
|
||||||
|
|
||||||
|
const payload = executionResult.responseBody ? JSON.parse(executionResult.responseBody) : {}
|
||||||
|
const normalizedRepos: Repo[] = (payload.repositories as Repo[] | undefined) || []
|
||||||
|
setRepos(normalizedRepos)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setError('Não foi possível sincronizar com o GitHub.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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">GitHub</p>
|
||||||
|
<h1 className="mt-2 text-2xl font-semibold text-slate-50">Sincronizar Repositórios</h1>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-col gap-3 sm:flex-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={accountId}
|
||||||
|
onChange={(e) => setAccountId(e.target.value)}
|
||||||
|
placeholder="ID do documento em cloud_accounts"
|
||||||
|
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
|
||||||
|
type="button"
|
||||||
|
onClick={handleSync}
|
||||||
|
disabled={loading || !accountId}
|
||||||
|
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'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <p className="mt-3 text-sm text-red-300">{error}</p> : null}
|
||||||
|
{execution ? (
|
||||||
|
<p className="mt-2 text-xs text-slate-400">Execução #{execution.$id} • Status: {execution.status}</p>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-xl border border-slate-800/70 bg-slate-950/60 shadow-inner shadow-slate-950/60">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-slate-800 text-left text-sm text-slate-200">
|
||||||
|
<thead className="bg-slate-900/80 text-xs uppercase tracking-[0.2em] text-cyan-200">
|
||||||
|
<tr>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<th key={column} className="px-4 py-3 font-semibold">
|
||||||
|
{column}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800">
|
||||||
|
{repos.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="px-4 py-6 text-center text-slate-500">
|
||||||
|
Nenhum repositório sincronizado ainda.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
repos.map((repo) => (
|
||||||
|
<tr key={repo.id} className="hover:bg-slate-900/50">
|
||||||
|
<td className="px-4 py-3 font-semibold text-slate-100">{repo.name}</td>
|
||||||
|
<td className="px-4 py-3 text-slate-300">{repo.language ?? 'N/A'}</td>
|
||||||
|
<td className="px-4 py-3 text-slate-300">{repo.stars ?? repo.stargazers_count ?? 0}</td>
|
||||||
|
<td className="px-4 py-3 text-slate-300">
|
||||||
|
{repo.last_commit || repo.pushed_at
|
||||||
|
? new Date(repo.last_commit || (repo.pushed_at as string)).toLocaleString()
|
||||||
|
: 'Sem dados'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
100
dashboard/src/pages/Home.tsx
Normal file
100
dashboard/src/pages/Home.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { Query } from 'appwrite'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { appwriteDatabaseId, databases } from '../lib/appwrite'
|
||||||
|
|
||||||
|
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">
|
||||||
|
<p className="text-xs uppercase tracking-[0.25em] text-cyan-300">{label}</p>
|
||||||
|
<p className="mt-2 text-3xl font-semibold text-slate-50">{value}</p>
|
||||||
|
{helper ? <p className="mt-1 text-xs text-slate-400">{helper}</p> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const [projectsTotal, setProjectsTotal] = useState<number | null>(null)
|
||||||
|
const [activeWorkers, setActiveWorkers] = useState<number | null>(null)
|
||||||
|
const [lastDeployment, setLastDeployment] = useState<string>('---')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchMetrics = async () => {
|
||||||
|
if (!appwriteDatabaseId) {
|
||||||
|
setError('Defina VITE_APPWRITE_DATABASE_ID para consultar métricas.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [projects, cloudAccounts, deployments] = await Promise.all([
|
||||||
|
databases.listDocuments(appwriteDatabaseId, 'projects', [Query.limit(1)]),
|
||||||
|
databases.listDocuments(appwriteDatabaseId, 'cloud_accounts', [Query.limit(1)]),
|
||||||
|
databases.listDocuments(appwriteDatabaseId, 'audit_logs', [Query.orderDesc('timestamp'), Query.limit(1)]),
|
||||||
|
])
|
||||||
|
|
||||||
|
setProjectsTotal(projects.total)
|
||||||
|
setActiveWorkers(cloudAccounts.total)
|
||||||
|
const lastTimestamp = deployments.documents[0]?.timestamp as string | undefined
|
||||||
|
setLastDeployment(lastTimestamp ? new Date(lastTimestamp).toLocaleString() : 'Sem deploys registrados')
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setError('Não foi possível carregar as métricas do painel.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchMetrics()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="rounded-xl border border-slate-800/80 bg-slate-900/60 p-6 shadow-lg shadow-slate-950/50">
|
||||||
|
<p className="text-xs uppercase tracking-[0.3em] text-cyan-300">Overview</p>
|
||||||
|
<h1 className="mt-2 text-2xl font-semibold text-slate-50">Saúde do Projeto</h1>
|
||||||
|
<p className="mt-2 text-sm text-slate-400">
|
||||||
|
Consolidação das integrações com GitHub, Cloudflare e Appwrite para acompanhar deploys e automações.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <div className="rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-200">{error}</div> : null}
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{statCard('Total Repos', projectsTotal ?? '---', 'Quantidade de projetos cadastrados no Appwrite')}
|
||||||
|
{statCard('Active Workers', activeWorkers ?? '---', 'Workers monitorados pelo Cloudflare')}
|
||||||
|
{statCard('Last Deployment', lastDeployment)}
|
||||||
|
</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">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-100">Deploys recentes</h3>
|
||||||
|
<p className="mt-2 text-sm text-slate-400">Acompanhe as últimas execuções registradas no Appwrite.</p>
|
||||||
|
<ul className="mt-4 space-y-3 text-sm text-slate-300">
|
||||||
|
<li className="flex items-center justify-between rounded-lg border border-slate-800/70 bg-slate-950/60 px-3 py-2">
|
||||||
|
<span>Build & Deploy</span>
|
||||||
|
<span className="text-xs text-emerald-300">{lastDeployment}</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center justify-between rounded-lg border border-slate-800/70 bg-slate-950/60 px-3 py-2">
|
||||||
|
<span>Sync GitHub</span>
|
||||||
|
<span className="text-xs text-emerald-300">{projectsTotal ?? '---'} repositórios</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-slate-800/70 bg-slate-900/70 p-5">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-100">Status de integrações</h3>
|
||||||
|
<div className="mt-4 space-y-3 text-sm text-slate-300">
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-slate-800/70 bg-slate-950/60 px-3 py-2">
|
||||||
|
<span>Appwrite Database</span>
|
||||||
|
<span className="text-xs text-cyan-300">{appwriteDatabaseId ? 'Configurado' : 'Pendente'}</span>
|
||||||
|
</div>
|
||||||
|
<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 className="text-xs text-cyan-300">{activeWorkers ?? '---'} credenciais</span>
|
||||||
|
</div>
|
||||||
|
<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 className="text-xs text-emerald-300">Monitorando audit_logs</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
85
dashboard/src/pages/Login.tsx
Normal file
85
dashboard/src/pages/Login.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { FormEvent, useState } from 'react'
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../contexts/Auth'
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const { login } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(email, password)
|
||||||
|
const redirectTo = (location.state as { from?: { pathname?: string } })?.from?.pathname || '/'
|
||||||
|
navigate(redirectTo, { replace: true })
|
||||||
|
} catch (err) {
|
||||||
|
setError('Falha ao autenticar. Confira as credenciais e tente novamente.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative min-h-screen overflow-hidden bg-slate-950">
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 opacity-60"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
'linear-gradient(rgba(59,130,246,0.08) 1px, transparent 1px), linear-gradient(90deg, rgba(14,165,233,0.08) 1px, transparent 1px)',
|
||||||
|
backgroundSize: '28px 28px',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<p className="text-xs uppercase tracking-[0.3em] text-cyan-300">Appwrite</p>
|
||||||
|
<h1 className="mt-2 text-2xl 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>
|
||||||
|
|
||||||
|
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-200">E-mail</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(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>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-200">Senha</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(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>
|
||||||
|
|
||||||
|
{error ? <p className="text-sm text-red-300">{error}</p> : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full 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 ? 'Entrando...' : 'Entrar'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
16
dashboard/src/pages/Settings.tsx
Normal file
16
dashboard/src/pages/Settings.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
export default function Settings() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-slate-800/70 bg-slate-900/70 p-6 shadow-inner shadow-slate-950/60">
|
||||||
|
<p className="text-xs uppercase tracking-[0.3em] text-cyan-300">Settings</p>
|
||||||
|
<h1 className="mt-2 text-2xl font-semibold text-slate-50">Preferências e Acesso</h1>
|
||||||
|
<p className="mt-2 text-sm text-slate-400">
|
||||||
|
Ajustes finos para notificações, tokens e comportamento das integrações. Expanda conforme novas features forem criadas.
|
||||||
|
</p>
|
||||||
|
<ul className="mt-4 space-y-3 text-sm text-slate-200">
|
||||||
|
<li className="rounded-lg border border-slate-800/70 bg-slate-950/60 px-3 py-2">Dark theme ativo</li>
|
||||||
|
<li className="rounded-lg border border-slate-800/70 bg-slate-950/60 px-3 py-2">Logs em tempo real habilitados</li>
|
||||||
|
<li className="rounded-lg border border-slate-800/70 bg-slate-950/60 px-3 py-2">Sessão Appwrite protegida</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
2055
package-lock.json
generated
Normal file
2055
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
14
package.json
Normal file
14
package.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"name": "core",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev:dashboard": "cd dashboard && npm run dev",
|
||||||
|
"dev:landing": "cd landing && deno task start",
|
||||||
|
"dev:web": "npm-run-all -p dev:dashboard dev:landing",
|
||||||
|
"lint:dashboard": "cd dashboard && npm run lint"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"npm-run-all": "^4.1.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue