mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 06:56:44 +00:00
Changes
Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
parent
75ad1de24d
commit
e6c9e42cff
3 changed files with 444 additions and 0 deletions
255
src/routes/admin.tsx
Normal file
255
src/routes/admin.tsx
Normal file
|
|
@ -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<string | null>(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 (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background px-4">
|
||||||
|
<div className="max-w-md text-center space-y-4">
|
||||||
|
<div className="mx-auto h-16 w-16 rounded-full bg-destructive/10 flex items-center justify-center">
|
||||||
|
<ShieldAlert className="h-8 w-8 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold">Acesso negado</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Esta área é restrita a administradores do Mika.
|
||||||
|
</p>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link to="/painel">
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" /> Voltar ao painel
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="min-h-screen bg-background px-4 sm:px-8 py-8">
|
||||||
|
<div className="mx-auto max-w-6xl space-y-6">
|
||||||
|
<header className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||||
|
<Server className="h-7 w-7 text-primary" /> Admin · Mika
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Gerencie agentes provisionados, suspenda e reative containers Railway.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<Link to="/painel">
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" /> Voltar ao painel
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-border bg-card p-4 sm:p-6 shadow-soft">
|
||||||
|
<h2 className="font-semibold mb-4">Agentes ({agents?.length ?? 0})</h2>
|
||||||
|
{agentsLoading ? (
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
) : !agents?.length ? (
|
||||||
|
<p className="text-sm text-muted-foreground py-8 text-center">
|
||||||
|
Nenhum agente cadastrado ainda.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Tenant</TableHead>
|
||||||
|
<TableHead>Bot</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Railway</TableHead>
|
||||||
|
<TableHead className="text-right">Ações</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{agents.map((a) => (
|
||||||
|
<TableRow key={a.id}>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{a.uuid_tenant.slice(0, 8)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{a.telegram_bot_username ? `@${a.telegram_bot_username}` : "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatusBadge status={a.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{a.railway_service_id?.slice(0, 8) ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right space-x-1 whitespace-nowrap">
|
||||||
|
{a.status === "provisioning" && !a.railway_service_id && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy === a.id + "provision-agent"}
|
||||||
|
onClick={() => action("provision-agent", a.id)}
|
||||||
|
>
|
||||||
|
{busy === a.id + "provision-agent" ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RotateCw className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
<span className="ml-1.5">Provisionar</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{a.status === "active" && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy === a.id + "suspend-agent"}
|
||||||
|
onClick={() => action("suspend-agent", a.id)}
|
||||||
|
>
|
||||||
|
{busy === a.id + "suspend-agent" ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<PauseCircle className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
<span className="ml-1.5">Suspender</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{a.status === "suspended" && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy === a.id + "resume-agent"}
|
||||||
|
onClick={() => action("resume-agent", a.id)}
|
||||||
|
>
|
||||||
|
{busy === a.id + "resume-agent" ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<PlayCircle className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
<span className="ml-1.5">Reativar</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
if (status === "active") return <Badge variant="success">Ativo</Badge>;
|
||||||
|
if (status === "provisioning") return <Badge variant="secondary">Provisionando</Badge>;
|
||||||
|
if (status === "suspended") return <Badge variant="outline">Suspenso</Badge>;
|
||||||
|
if (status === "error") return <Badge variant="destructive">Erro</Badge>;
|
||||||
|
return <Badge variant="secondary">{status}</Badge>;
|
||||||
|
}
|
||||||
94
supabase/functions/resume-agent/index.ts
Normal file
94
supabase/functions/resume-agent/index.ts
Normal file
|
|
@ -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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
95
supabase/functions/suspend-agent/index.ts
Normal file
95
supabase/functions/suspend-agent/index.ts
Normal file
|
|
@ -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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue