diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx new file mode 100644 index 0000000..f5fda66 --- /dev/null +++ b/src/routes/admin.tsx @@ -0,0 +1,255 @@ +"use client"; + +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { + ArrowLeft, + Loader2, + PlayCircle, + PauseCircle, + RotateCw, + Server, + ShieldAlert, +} from "lucide-react"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/hooks/use-auth"; +import { invokeFunction } from "@/lib/invoke-function"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +export const Route = createFileRoute("/admin")({ + component: AdminPage, +}); + +interface AgentRow { + id: string; + user_id: string; + status: string; + uuid_tenant: string; + telegram_bot_username: string | null; + railway_service_id: string | null; + vps_pool_id: string | null; + created_at: string; + provisioned_at: string | null; +} + +function AdminPage() { + const { user, loading: authLoading } = useAuth(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [busy, setBusy] = useState(null); + + const { data: isAdmin, isLoading: roleLoading } = useQuery({ + queryKey: ["is-admin", user?.id], + enabled: !!user, + queryFn: async () => { + const { data, error } = await supabase.rpc("has_role", { + _user_id: user!.id, + _role: "admin", + }); + if (error) throw error; + return data === true; + }, + }); + + const { data: agents, isLoading: agentsLoading } = useQuery({ + queryKey: ["admin-agents"], + enabled: !!isAdmin, + queryFn: async () => { + const { data, error } = await supabase + .from("agent_instances") + .select( + "id, user_id, status, uuid_tenant, telegram_bot_username, railway_service_id, vps_pool_id, created_at, provisioned_at", + ) + .order("created_at", { ascending: false }) + .limit(100); + if (error) throw error; + return data as AgentRow[]; + }, + }); + + useEffect(() => { + if (!authLoading && !user) { + navigate({ to: "/login", search: { redirect: "/admin" } }); + } + }, [authLoading, user, navigate]); + + if (authLoading || roleLoading) { + return ( +
+ +
+ ); + } + + if (!isAdmin) { + return ( +
+
+
+ +
+

Acesso negado

+

+ Esta área é restrita a administradores do Mika. +

+ +
+
+ ); + } + + async function action( + fn: "provision-agent" | "suspend-agent" | "resume-agent", + agentId: string, + ) { + setBusy(agentId + fn); + const { data, error } = await invokeFunction<{ ok?: boolean; error?: string }>(fn, { + agent_instance_id: agentId, + }); + setBusy(null); + if (error) { + toast.error(`${fn} falhou: ${error.message}`); + } else if (data?.error) { + toast.error(`${fn}: ${data.error}`); + } else { + toast.success(`${fn} executado com sucesso`); + queryClient.invalidateQueries({ queryKey: ["admin-agents"] }); + } + } + + return ( +
+
+
+
+

+ Admin · Mika +

+

+ Gerencie agentes provisionados, suspenda e reative containers Railway. +

+
+ +
+ +
+

Agentes ({agents?.length ?? 0})

+ {agentsLoading ? ( + + ) : !agents?.length ? ( +

+ Nenhum agente cadastrado ainda. +

+ ) : ( +
+ + + + Tenant + Bot + Status + Railway + Ações + + + + {agents.map((a) => ( + + + {a.uuid_tenant.slice(0, 8)} + + + {a.telegram_bot_username ? `@${a.telegram_bot_username}` : "—"} + + + + + + {a.railway_service_id?.slice(0, 8) ?? "—"} + + + {a.status === "provisioning" && !a.railway_service_id && ( + + )} + {a.status === "active" && ( + + )} + {a.status === "suspended" && ( + + )} + + + ))} + +
+
+ )} +
+
+
+ ); +} + +function StatusBadge({ status }: { status: string }) { + if (status === "active") return Ativo; + if (status === "provisioning") return Provisionando; + if (status === "suspended") return Suspenso; + if (status === "error") return Erro; + return {status}; +} diff --git a/supabase/functions/resume-agent/index.ts b/supabase/functions/resume-agent/index.ts new file mode 100644 index 0000000..525a208 --- /dev/null +++ b/supabase/functions/resume-agent/index.ts @@ -0,0 +1,94 @@ +// resume-agent +// Retoma o serviço Railway (numReplicas=1) e marca agent_instance.status='active'. +// Disparado automaticamente quando subscription volta para active/trialing, +// ou manualmente pelo painel admin. + +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4"; +import { corsHeaders } from "../_shared/cors.ts"; +import { setRailwayReplicas } from "../_shared/railway.ts"; + +const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; +const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; +const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN"); + +interface RequestBody { + agent_instance_id: string; +} + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); + + if (!RAILWAY_API_TOKEN) { + return jsonResponse(500, { error: "RAILWAY_API_TOKEN not configured" }); + } + + let body: RequestBody; + try { + body = await req.json(); + } catch { + return jsonResponse(400, { error: "invalid json body" }); + } + + if (!body.agent_instance_id) { + return jsonResponse(400, { error: "agent_instance_id required" }); + } + + const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + + const { data: agent } = await supabase + .from("agent_instances") + .select("id, status, railway_service_id, vps_pool_id") + .eq("id", body.agent_instance_id) + .maybeSingle(); + + if (!agent) return jsonResponse(404, { error: "agent_instance not found" }); + + if (!agent.railway_service_id || !agent.vps_pool_id) { + return jsonResponse(409, { + error: "agent has no container — needs full provisioning instead", + }); + } + + if (agent.status === "active") { + return jsonResponse(200, { ok: true, already_active: true }); + } + + const { data: pool } = await supabase + .from("vps_pool") + .select("railway_environment_id") + .eq("id", agent.vps_pool_id) + .maybeSingle(); + + if (!pool?.railway_environment_id) { + return jsonResponse(500, { error: "pool environment not configured" }); + } + + try { + await setRailwayReplicas({ + token: RAILWAY_API_TOKEN, + serviceId: agent.railway_service_id, + environmentId: pool.railway_environment_id, + replicas: 1, + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error("resume-agent: setRailwayReplicas failed:", msg); + return jsonResponse(500, { error: "railway scale-up failed", detail: msg }); + } + + await supabase + .from("agent_instances") + .update({ status: "active", last_health_check_at: new Date().toISOString() }) + .eq("id", agent.id); + + return jsonResponse(200, { ok: true, agent_id: agent.id, new_status: "active" }); +}); + +function jsonResponse(status: number, body: unknown) { + return new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); +} diff --git a/supabase/functions/suspend-agent/index.ts b/supabase/functions/suspend-agent/index.ts new file mode 100644 index 0000000..f81837b --- /dev/null +++ b/supabase/functions/suspend-agent/index.ts @@ -0,0 +1,95 @@ +// suspend-agent +// Pausa o serviço Railway (numReplicas=0) e marca agent_instance.status='suspended'. +// Disparado automaticamente quando subscription muda para canceled/past_due/unpaid/paused, +// ou manualmente pelo painel admin. + +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4"; +import { corsHeaders } from "../_shared/cors.ts"; +import { setRailwayReplicas } from "../_shared/railway.ts"; + +const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; +const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; +const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN"); + +interface RequestBody { + agent_instance_id: string; +} + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); + + if (!RAILWAY_API_TOKEN) { + return jsonResponse(500, { error: "RAILWAY_API_TOKEN not configured" }); + } + + let body: RequestBody; + try { + body = await req.json(); + } catch { + return jsonResponse(400, { error: "invalid json body" }); + } + + if (!body.agent_instance_id) { + return jsonResponse(400, { error: "agent_instance_id required" }); + } + + const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + + const { data: agent } = await supabase + .from("agent_instances") + .select("id, status, railway_service_id, vps_pool_id") + .eq("id", body.agent_instance_id) + .maybeSingle(); + + if (!agent) return jsonResponse(404, { error: "agent_instance not found" }); + if (agent.status === "suspended") { + return jsonResponse(200, { ok: true, already_suspended: true }); + } + if (!agent.railway_service_id || !agent.vps_pool_id) { + // Sem container provisionado ainda — só marca o status + await supabase + .from("agent_instances") + .update({ status: "suspended" }) + .eq("id", agent.id); + return jsonResponse(200, { ok: true, no_container: true }); + } + + const { data: pool } = await supabase + .from("vps_pool") + .select("railway_environment_id") + .eq("id", agent.vps_pool_id) + .maybeSingle(); + + if (!pool?.railway_environment_id) { + return jsonResponse(500, { error: "pool environment not configured" }); + } + + try { + await setRailwayReplicas({ + token: RAILWAY_API_TOKEN, + serviceId: agent.railway_service_id, + environmentId: pool.railway_environment_id, + replicas: 0, + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error("suspend-agent: setRailwayReplicas failed:", msg); + return jsonResponse(500, { error: "railway scale-down failed", detail: msg }); + } + + await supabase + .from("agent_instances") + .update({ status: "suspended" }) + .eq("id", agent.id); + + return jsonResponse(200, { ok: true, agent_id: agent.id, new_status: "suspended" }); +}); + +function jsonResponse(status: number, body: unknown) { + return new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); +}