Corrigiu search params obrigatório

X-Lovable-Edit-ID: edt-454c8f6f-1bdf-4e9e-9a14-027e8ebe3a47
Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot] 2026-04-22 17:29:00 +00:00
commit 16b1210c5a
18 changed files with 570 additions and 210 deletions

View file

@ -57,7 +57,7 @@ export function LandingHeader() {
<ThemeToggle /> <ThemeToggle />
{user ? ( {user ? (
<Button asChild className="rounded-lg bg-primary hover:bg-primary-dark text-primary-foreground transition-all duration-150 active:scale-[0.98]"> <Button asChild className="rounded-lg bg-primary hover:bg-primary-dark text-primary-foreground transition-all duration-150 active:scale-[0.98]">
<Link to="/painel">Ir para o painel</Link> <Link to="/painel" search={{}}>Ir para o painel</Link>
</Button> </Button>
) : ( ) : (
<> <>
@ -98,7 +98,7 @@ export function LandingHeader() {
<div className="mt-6 flex flex-col gap-3"> <div className="mt-6 flex flex-col gap-3">
{user ? ( {user ? (
<Button asChild className="rounded-lg w-full bg-primary hover:bg-primary-dark text-primary-foreground"> <Button asChild className="rounded-lg w-full bg-primary hover:bg-primary-dark text-primary-foreground">
<Link to="/painel" onClick={() => setOpen(false)}>Ir para o painel</Link> <Link to="/painel" search={{}} onClick={() => setOpen(false)}>Ir para o painel</Link>
</Button> </Button>
) : ( ) : (
<> <>

View file

@ -324,7 +324,7 @@ export function CronjobWizard({ onCreated, onCancel }: Props) {
Conecte as integrações faltantes antes de criar. Conecte as integrações faltantes antes de criar.
</p> </p>
<Button asChild size="sm" variant="outline" className="mt-2"> <Button asChild size="sm" variant="outline" className="mt-2">
<Link to="/painel/integracoes">Ir para Integrações</Link> <Link to="/painel/integracoes" search={{}}>Ir para Integrações</Link>
</Button> </Button>
</div> </div>
)} )}

View file

@ -40,7 +40,7 @@ export function IntegrationsDashboardWidget() {
</div> </div>
</div> </div>
<Button asChild variant="ghost" size="sm" className="text-primary hover:text-primary"> <Button asChild variant="ghost" size="sm" className="text-primary hover:text-primary">
<Link to="/painel/integracoes"> <Link to="/painel/integracoes" search={{}}>
Gerenciar <ArrowRight className="ml-1 h-3.5 w-3.5" /> Gerenciar <ArrowRight className="ml-1 h-3.5 w-3.5" />
</Link> </Link>
</Button> </Button>
@ -52,7 +52,7 @@ export function IntegrationsDashboardWidget() {
Conecte serviços como Gmail, Notion e Cal.com para ampliar seu Mika. Conecte serviços como Gmail, Notion e Cal.com para ampliar seu Mika.
</p> </p>
<Button asChild size="sm" variant="outline" className="rounded-lg"> <Button asChild size="sm" variant="outline" className="rounded-lg">
<Link to="/painel/integracoes">Explorar integrações</Link> <Link to="/painel/integracoes" search={{}}>Explorar integrações</Link>
</Button> </Button>
</div> </div>
) : ( ) : (

View file

@ -1,67 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { Loader2, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { invokeFunction } from "@/lib/invoke-function";
interface Props {
onConfigured: () => void;
onSkip: () => void;
}
export function StepConfiguring({ onConfigured, onSkip }: Props) {
const [state, setState] = useState<"loading" | "error">("loading");
const [error, setError] = useState<string | null>(null);
const [attempt, setAttempt] = useState(0);
useEffect(() => {
let cancelled = false;
setState("loading");
setError(null);
(async () => {
const { error: err } = await invokeFunction("configure-telegram-webhook");
if (cancelled) return;
if (err) {
setError(err.message);
setState("error");
return;
}
window.setTimeout(() => {
if (!cancelled) onConfigured();
}, 800);
})();
return () => {
cancelled = true;
};
}, [attempt, onConfigured]);
return (
<div className="px-6 py-12 flex flex-col items-center justify-center min-h-[320px]">
{state === "loading" && (
<>
<Loader2 className="h-10 w-10 text-primary animate-spin" />
<p className="mt-4 text-sm text-muted-foreground">
Configurando recebimento de mensagens...
</p>
</>
)}
{state === "error" && (
<div className="w-full max-w-md rounded-lg border border-destructive bg-destructive/5 p-6 text-center">
<AlertCircle className="mx-auto h-8 w-8 text-destructive" />
<h3 className="mt-3 font-semibold">Falha ao configurar webhook</h3>
{error && <p className="mt-2 text-sm text-muted-foreground">{error}</p>}
<div className="mt-5 flex flex-col sm:flex-row gap-2 justify-center">
<Button onClick={() => setAttempt((a) => a + 1)}>Tentar novamente</Button>
<Button variant="ghost" onClick={onSkip}>
Pular (configurar depois)
</Button>
</div>
</div>
)}
</div>
);
}

View file

@ -1,71 +0,0 @@
"use client";
import { useState } from "react";
import { Copy, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
interface Props {
suggestedName: string;
suggestedUsername: string;
onNext: () => void;
}
export function StepNaming({ suggestedName, suggestedUsername, onNext }: Props) {
return (
<div className="px-6 py-8 max-w-2xl mx-auto">
<h2 className="text-2xl font-bold tracking-tight">Escolha os nomes</h2>
<p className="mt-2 text-muted-foreground">
O BotFather vai pedir dois nomes. Use nossas sugestões se quiser.
</p>
<div className="mt-6 space-y-4">
<CopyField
label="Nome do bot"
value={suggestedName}
help="Esse é o nome que aparece no topo da conversa."
/>
<CopyField
label="Username (termina em bot)"
value={suggestedUsername}
help="Se o username já estiver em uso, tente adicionar um número no final."
/>
</div>
<div className="mt-8 flex justify-end">
<Button onClick={onNext}>Próximo: colar o token</Button>
</div>
</div>
);
}
function CopyField({ label, value, help }: { label: string; value: string; help?: string }) {
const [copied, setCopied] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
toast.success("Copiado para a área de transferência");
window.setTimeout(() => setCopied(false), 1500);
} catch {
toast.error("Não foi possível copiar");
}
}
return (
<div className="rounded-lg border border-border bg-card p-4">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{label}
</label>
<div className="mt-2 flex gap-2">
<Input value={value} readOnly className="font-mono text-sm" />
<Button type="button" variant="outline" size="icon" onClick={copy} aria-label="Copiar">
{copied ? <Check className="h-4 w-4 text-success" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
{help && <p className="mt-2 text-xs text-muted-foreground">{help}</p>}
</div>
);
}

View file

@ -1,37 +1,32 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import * as DialogPrimitive from "@radix-ui/react-dialog"; import * as DialogPrimitive from "@radix-ui/react-dialog";
import { useProfile } from "@/hooks/use-profile";
import { useAgentInstance } from "@/hooks/use-agent-instance"; import { useAgentInstance } from "@/hooks/use-agent-instance";
import { supabase } from "@/integrations/supabase/client"; import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { suggestBotName, suggestBotUsername } from "@/lib/telegram-username";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { toast } from "sonner"; import { toast } from "sonner";
import { StepWelcome } from "./StepWelcome"; import { StepWelcome } from "./StepWelcome";
import { StepCreateBot } from "./StepCreateBot"; import { StepCreateBot } from "./StepCreateBot";
import { StepNaming } from "./StepNaming";
import { StepToken, type ValidatedBot } from "./StepToken"; import { StepToken, type ValidatedBot } from "./StepToken";
import { StepConfiguring } from "./StepConfiguring";
import { StepWaiting } from "./StepWaiting"; import { StepWaiting } from "./StepWaiting";
const STORAGE_KEY = "mika-onboarding-last-step"; const STORAGE_KEY = "mika-onboarding-last-step";
const TOTAL_STEPS = 6; const TOTAL_STEPS = 4;
interface Props { interface Props {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
/** Step inicial (1-6). Se omitido, lê do localStorage ou começa em 1. */ /** Step inicial (1-4). Se omitido, lê do localStorage ou começa em 1. */
initialStep?: number; initialStep?: number;
} }
export function TelegramOnboardingWizard({ open, onOpenChange, initialStep }: Props) { export function TelegramOnboardingWizard({ open, onOpenChange, initialStep }: Props) {
const { data: profile } = useProfile();
const { data: agent } = useAgentInstance(); const { data: agent } = useAgentInstance();
const { user } = useAuth(); const { user } = useAuth();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -39,12 +34,6 @@ export function TelegramOnboardingWizard({ open, onOpenChange, initialStep }: Pr
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
const [validated, setValidated] = useState<ValidatedBot | null>(null); const [validated, setValidated] = useState<ValidatedBot | null>(null);
const suggestedName = useMemo(() => suggestBotName(profile?.full_name), [profile?.full_name]);
const suggestedUsername = useMemo(
() => suggestBotUsername(profile?.full_name),
[profile?.full_name],
);
// Inicializa step ao abrir // Inicializa step ao abrir
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@ -101,13 +90,6 @@ export function TelegramOnboardingWizard({ open, onOpenChange, initialStep }: Pr
} }
} }
function handleConfigured() {
if (user) {
queryClient.invalidateQueries({ queryKey: ["agent-instance", user.id] });
}
setStep(6);
}
const botUsername = validated?.bot_username ?? agent?.telegram_bot_username ?? ""; const botUsername = validated?.bot_username ?? agent?.telegram_bot_username ?? "";
const connectedAt = agent?.telegram_connected_at ?? null; const connectedAt = agent?.telegram_connected_at ?? null;
@ -118,9 +100,7 @@ export function TelegramOnboardingWizard({ open, onOpenChange, initialStep }: Pr
<DialogPrimitive.Content <DialogPrimitive.Content
className={cn( className={cn(
"fixed z-50 bg-background shadow-2xl", "fixed z-50 bg-background shadow-2xl",
// Mobile: full-screen
"inset-0 sm:inset-auto", "inset-0 sm:inset-auto",
// Desktop: dialog grande centralizado
"sm:left-[50%] sm:top-[50%] sm:translate-x-[-50%] sm:translate-y-[-50%]", "sm:left-[50%] sm:top-[50%] sm:translate-x-[-50%] sm:translate-y-[-50%]",
"sm:w-full sm:max-w-2xl sm:rounded-2xl sm:max-h-[90vh] sm:overflow-y-auto", "sm:w-full sm:max-w-2xl sm:rounded-2xl sm:max-h-[90vh] sm:overflow-y-auto",
"data-[state=open]:animate-in data-[state=closed]:animate-out", "data-[state=open]:animate-in data-[state=closed]:animate-out",
@ -130,10 +110,9 @@ export function TelegramOnboardingWizard({ open, onOpenChange, initialStep }: Pr
Conectar Telegram ao Mika Conectar Telegram ao Mika
</DialogPrimitive.Title> </DialogPrimitive.Title>
<DialogPrimitive.Description className="sr-only"> <DialogPrimitive.Description className="sr-only">
Wizard guiado de 6 passos para conectar seu agente Mika ao Telegram. Wizard guiado para conectar seu agente Mika ao Telegram em 4 passos.
</DialogPrimitive.Description> </DialogPrimitive.Description>
{/* Header com progress + close */}
<div className="sticky top-0 z-10 bg-background/95 backdrop-blur-sm border-b border-border"> <div className="sticky top-0 z-10 bg-background/95 backdrop-blur-sm border-b border-border">
<div className="flex items-center justify-between px-4 sm:px-6 py-3"> <div className="flex items-center justify-between px-4 sm:px-6 py-3">
<span className="text-xs font-medium text-muted-foreground"> <span className="text-xs font-medium text-muted-foreground">
@ -157,7 +136,6 @@ export function TelegramOnboardingWizard({ open, onOpenChange, initialStep }: Pr
</div> </div>
</div> </div>
{/* Conteúdo dos steps */}
<div className="relative overflow-hidden"> <div className="relative overflow-hidden">
<AnimatePresence mode="wait" initial={false}> <AnimatePresence mode="wait" initial={false}>
<motion.div <motion.div
@ -170,26 +148,13 @@ export function TelegramOnboardingWizard({ open, onOpenChange, initialStep }: Pr
{step === 1 && <StepWelcome onNext={() => setStep(2)} />} {step === 1 && <StepWelcome onNext={() => setStep(2)} />}
{step === 2 && <StepCreateBot onNext={() => setStep(3)} />} {step === 2 && <StepCreateBot onNext={() => setStep(3)} />}
{step === 3 && ( {step === 3 && (
<StepNaming
suggestedName={suggestedName}
suggestedUsername={suggestedUsername}
onNext={() => setStep(4)}
/>
)}
{step === 4 && (
<StepToken <StepToken
validated={validated} validated={validated}
onValidated={handleValidated} onValidated={handleValidated}
onNext={() => setStep(5)} onNext={() => setStep(4)}
/> />
)} )}
{step === 5 && ( {step === 4 && agent && botUsername && (
<StepConfiguring
onConfigured={handleConfigured}
onSkip={() => setStep(6)}
/>
)}
{step === 6 && agent && botUsername && (
<StepWaiting <StepWaiting
agentInstanceId={agent.id} agentInstanceId={agent.id}
botUsername={botUsername} botUsername={botUsername}
@ -197,7 +162,7 @@ export function TelegramOnboardingWizard({ open, onOpenChange, initialStep }: Pr
onFinish={handleFinish} onFinish={handleFinish}
/> />
)} )}
{step === 6 && (!agent || !botUsername) && ( {step === 4 && (!agent || !botUsername) && (
<div className="px-6 py-12 text-center text-sm text-muted-foreground"> <div className="px-6 py-12 text-center text-sm text-muted-foreground">
Conecte o bot primeiro para receber a primeira mensagem. Conecte o bot primeiro para receber a primeira mensagem.
</div> </div>

View file

@ -14,6 +14,7 @@ import { Route as RedefinirSenhaRouteImport } from './routes/redefinir-senha'
import { Route as RecuperarSenhaRouteImport } from './routes/recuperar-senha' import { Route as RecuperarSenhaRouteImport } from './routes/recuperar-senha'
import { Route as PainelRouteImport } from './routes/painel' import { Route as PainelRouteImport } from './routes/painel'
import { Route as LoginRouteImport } from './routes/login' import { Route as LoginRouteImport } from './routes/login'
import { Route as AdminRouteImport } from './routes/admin'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
import { Route as PainelIndexRouteImport } from './routes/painel.index' import { Route as PainelIndexRouteImport } from './routes/painel.index'
import { Route as PainelSkillsRouteImport } from './routes/painel.skills' import { Route as PainelSkillsRouteImport } from './routes/painel.skills'
@ -56,6 +57,11 @@ const LoginRoute = LoginRouteImport.update({
path: '/login', path: '/login',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const AdminRoute = AdminRouteImport.update({
id: '/admin',
path: '/admin',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({ const IndexRoute = IndexRouteImport.update({
id: '/', id: '/',
path: '/', path: '/',
@ -139,6 +145,7 @@ const PainelCronjobsIdRoute = PainelCronjobsIdRouteImport.update({
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/admin': typeof AdminRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/painel': typeof PainelRouteWithChildren '/painel': typeof PainelRouteWithChildren
'/recuperar-senha': typeof RecuperarSenhaRoute '/recuperar-senha': typeof RecuperarSenhaRoute
@ -162,6 +169,7 @@ export interface FileRoutesByFullPath {
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/admin': typeof AdminRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/recuperar-senha': typeof RecuperarSenhaRoute '/recuperar-senha': typeof RecuperarSenhaRoute
'/redefinir-senha': typeof RedefinirSenhaRoute '/redefinir-senha': typeof RedefinirSenhaRoute
@ -184,6 +192,7 @@ export interface FileRoutesByTo {
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/admin': typeof AdminRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/painel': typeof PainelRouteWithChildren '/painel': typeof PainelRouteWithChildren
'/recuperar-senha': typeof RecuperarSenhaRoute '/recuperar-senha': typeof RecuperarSenhaRoute
@ -209,6 +218,7 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: fullPaths:
| '/' | '/'
| '/admin'
| '/login' | '/login'
| '/painel' | '/painel'
| '/recuperar-senha' | '/recuperar-senha'
@ -232,6 +242,7 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
| '/admin'
| '/login' | '/login'
| '/recuperar-senha' | '/recuperar-senha'
| '/redefinir-senha' | '/redefinir-senha'
@ -253,6 +264,7 @@ export interface FileRouteTypes {
id: id:
| '__root__' | '__root__'
| '/' | '/'
| '/admin'
| '/login' | '/login'
| '/painel' | '/painel'
| '/recuperar-senha' | '/recuperar-senha'
@ -277,6 +289,7 @@ export interface FileRouteTypes {
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
AdminRoute: typeof AdminRoute
LoginRoute: typeof LoginRoute LoginRoute: typeof LoginRoute
PainelRoute: typeof PainelRouteWithChildren PainelRoute: typeof PainelRouteWithChildren
RecuperarSenhaRoute: typeof RecuperarSenhaRoute RecuperarSenhaRoute: typeof RecuperarSenhaRoute
@ -322,6 +335,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LoginRouteImport preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/admin': {
id: '/admin'
path: '/admin'
fullPath: '/admin'
preLoaderRoute: typeof AdminRouteImport
parentRoute: typeof rootRouteImport
}
'/': { '/': {
id: '/' id: '/'
path: '/' path: '/'
@ -486,6 +506,7 @@ const PainelRouteWithChildren =
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
AdminRoute: AdminRoute,
LoginRoute: LoginRoute, LoginRoute: LoginRoute,
PainelRoute: PainelRouteWithChildren, PainelRoute: PainelRouteWithChildren,
RecuperarSenhaRoute: RecuperarSenhaRoute, RecuperarSenhaRoute: RecuperarSenhaRoute,

255
src/routes/admin.tsx Normal file
View 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>;
}

View file

@ -18,7 +18,7 @@ function CheckoutSuccessPage() {
// Invalida queries de assinatura para refletir o novo estado quando o webhook chegar // Invalida queries de assinatura para refletir o novo estado quando o webhook chegar
queryClient.invalidateQueries({ queryKey: ["subscription"] }); queryClient.invalidateQueries({ queryKey: ["subscription"] });
const t = setTimeout(() => { const t = setTimeout(() => {
navigate({ to: "/painel" }); navigate({ to: "/painel", search: {} });
}, 6000); }, 6000);
return () => clearTimeout(t); return () => clearTimeout(t);
}, [navigate, queryClient]); }, [navigate, queryClient]);
@ -37,7 +37,7 @@ function CheckoutSuccessPage() {
</div> </div>
<div className="flex flex-col sm:flex-row gap-3 justify-center"> <div className="flex flex-col sm:flex-row gap-3 justify-center">
<Button asChild className="rounded-lg bg-primary hover:bg-primary-dark text-primary-foreground"> <Button asChild className="rounded-lg bg-primary hover:bg-primary-dark text-primary-foreground">
<Link to="/painel">Ir para o painel</Link> <Link to="/painel" search={{}}>Ir para o painel</Link>
</Button> </Button>
<Button asChild variant="outline" className="rounded-lg"> <Button asChild variant="outline" className="rounded-lg">
<Link to="/painel/faturamento">Ver faturamento</Link> <Link to="/painel/faturamento">Ver faturamento</Link>

View file

@ -50,7 +50,7 @@ function LoginPage() {
return; return;
} }
toast.success("Bem-vindo de volta!"); toast.success("Bem-vindo de volta!");
navigate({ to: (redirect as "/painel") || "/painel" }); navigate({ to: (redirect as "/painel") || "/painel", search: {} });
}; };
return ( return (

View file

@ -149,7 +149,7 @@ function CronjobDetailPage() {
))} ))}
</ul> </ul>
<Button asChild size="sm" variant="outline" className="mt-3"> <Button asChild size="sm" variant="outline" className="mt-3">
<Link to="/painel/integracoes">Conectar agora</Link> <Link to="/painel/integracoes" search={{}}>Conectar agora</Link>
</Button> </Button>
</div> </div>
)} )}

View file

@ -1,8 +1,6 @@
"use client"; "use client";
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute, Link } from "@tanstack/react-router";
import { zodValidator, fallback } from "@tanstack/zod-adapter";
import { z } from "zod";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ArrowRight, CheckCircle2, Loader2, Sparkles } from "lucide-react"; import { ArrowRight, CheckCircle2, Loader2, Sparkles } from "lucide-react";
import { useSubscription } from "@/hooks/use-profile"; import { useSubscription } from "@/hooks/use-profile";
@ -19,12 +17,12 @@ import { TelegramOnboardingWizard } from "@/components/mika/telegram/TelegramOnb
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const dashboardSearchSchema = z.object({ type DashboardSearch = { status?: string };
status: fallback(z.string().optional(), undefined),
});
export const Route = createFileRoute("/painel/")({ export const Route = createFileRoute("/painel/")({
validateSearch: zodValidator(dashboardSearchSchema), validateSearch: (search: Record<string, unknown>): DashboardSearch => ({
status: typeof search.status === "string" ? search.status : undefined,
}),
component: DashboardPage, component: DashboardPage,
}); });

View file

@ -69,7 +69,7 @@ function IntegrationDetailPage() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Button variant="ghost" size="sm" asChild> <Button variant="ghost" size="sm" asChild>
<Link to="/painel/integracoes"> <Link to="/painel/integracoes" search={{}}>
<ArrowLeft className="h-4 w-4 mr-2" /> Voltar <ArrowLeft className="h-4 w-4 mr-2" /> Voltar
</Link> </Link>
</Button> </Button>
@ -84,7 +84,7 @@ function IntegrationDetailPage() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Button variant="ghost" size="sm" asChild> <Button variant="ghost" size="sm" asChild>
<Link to="/painel/integracoes"> <Link to="/painel/integracoes" search={{}}>
<ArrowLeft className="h-4 w-4 mr-2" /> Voltar <ArrowLeft className="h-4 w-4 mr-2" /> Voltar
</Link> </Link>
</Button> </Button>
@ -93,7 +93,7 @@ function IntegrationDetailPage() {
Você ainda não conectou {mcp.name}. Você ainda não conectou {mcp.name}.
</p> </p>
<Button asChild className="mt-4"> <Button asChild className="mt-4">
<Link to="/painel/integracoes">Conectar</Link> <Link to="/painel/integracoes" search={{}}>Conectar</Link>
</Button> </Button>
</div> </div>
</div> </div>
@ -154,7 +154,7 @@ function IntegrationDetailPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Button variant="ghost" size="sm" asChild> <Button variant="ghost" size="sm" asChild>
<Link to="/painel/integracoes"> <Link to="/painel/integracoes" search={{}}>
<ArrowLeft className="h-4 w-4 mr-2" /> Voltar <ArrowLeft className="h-4 w-4 mr-2" /> Voltar
</Link> </Link>
</Button> </Button>
@ -298,7 +298,7 @@ function IntegrationDetailPage() {
.then(() => { .then(() => {
const stillConnected = integs.some((i) => i.id === integration.id); const stillConnected = integs.some((i) => i.id === integration.id);
if (!stillConnected) { if (!stillConnected) {
navigate({ to: "/painel/integracoes" }); navigate({ to: "/painel/integracoes", search: {} });
} }
}); });
} }

View file

@ -1,8 +1,6 @@
"use client"; "use client";
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { zodValidator, fallback } from "@tanstack/zod-adapter";
import { z } from "zod";
import { Plug } from "lucide-react"; import { Plug } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@ -11,14 +9,14 @@ import { useIntegrationCards } from "@/hooks/use-integrations";
import { useAgentInstance } from "@/hooks/use-agent-instance"; import { useAgentInstance } from "@/hooks/use-agent-instance";
import { IntegrationCard } from "@/components/mika/integrations/IntegrationCard"; import { IntegrationCard } from "@/components/mika/integrations/IntegrationCard";
const integracoesSearchSchema = z.object({ type IntegracoesSearch = { status?: string; error?: string; mcp?: string };
status: fallback(z.string().optional(), undefined),
error: fallback(z.string().optional(), undefined),
mcp: fallback(z.string().optional(), undefined),
});
export const Route = createFileRoute("/painel/integracoes/")({ export const Route = createFileRoute("/painel/integracoes/")({
validateSearch: zodValidator(integracoesSearchSchema), validateSearch: (search: Record<string, unknown>): IntegracoesSearch => ({
status: typeof search.status === "string" ? search.status : undefined,
error: typeof search.error === "string" ? search.error : undefined,
mcp: typeof search.mcp === "string" ? search.mcp : undefined,
}),
component: IntegracoesPage, component: IntegracoesPage,
}); });

View file

@ -86,9 +86,9 @@ function SignupPage() {
toast.success("Conta criada!"); toast.success("Conta criada!");
if (plan && plan !== "enterprise") { if (plan && plan !== "enterprise") {
// TODO Etapa 3: chamar create-checkout-session com plan/cycle // TODO Etapa 3: chamar create-checkout-session com plan/cycle
navigate({ to: "/painel" }); navigate({ to: "/painel", search: {} });
} else { } else {
navigate({ to: "/painel" }); navigate({ to: "/painel", search: {} });
} }
return; return;
} }

View 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" },
});
}

View 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" },
});
}

View file

@ -0,0 +1,72 @@
-- Trigger para chamar suspend-agent quando subscription fica inactive (canceled/past_due)
-- e resume-agent quando volta para active.
CREATE OR REPLACE FUNCTION public.trigger_suspend_or_resume_agent()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = 'public', 'extensions'
AS $$
DECLARE
v_supabase_url text;
v_anon_key text;
v_function text;
v_agent_id uuid;
BEGIN
-- Só age em UPDATE, não em INSERT inicial
IF TG_OP <> 'UPDATE' THEN
RETURN NEW;
END IF;
IF NEW.status = OLD.status THEN
RETURN NEW;
END IF;
-- Determina ação: active/trialing => resume; canceled/past_due/unpaid => suspend
IF NEW.status IN ('active', 'trialing') AND OLD.status NOT IN ('active', 'trialing') THEN
v_function := 'resume-agent';
ELSIF NEW.status IN ('canceled', 'past_due', 'unpaid', 'paused') AND OLD.status IN ('active', 'trialing') THEN
v_function := 'suspend-agent';
ELSE
RETURN NEW;
END IF;
-- Busca o agent_instance correspondente
SELECT id INTO v_agent_id FROM public.agent_instances WHERE user_id = NEW.user_id LIMIT 1;
IF v_agent_id IS NULL THEN
RETURN NEW;
END IF;
-- Lê config
BEGIN
SELECT decrypted_secret INTO v_supabase_url
FROM vault.decrypted_secrets WHERE name = 'project_url' LIMIT 1;
SELECT decrypted_secret INTO v_anon_key
FROM vault.decrypted_secrets WHERE name = 'anon_key' LIMIT 1;
EXCEPTION WHEN OTHERS THEN
v_supabase_url := NULL;
END;
IF v_supabase_url IS NULL OR v_anon_key IS NULL THEN
RAISE LOG 'trigger_suspend_or_resume_agent: vault não configurado, pulando';
RETURN NEW;
END IF;
PERFORM net.http_post(
url := v_supabase_url || '/functions/v1/' || v_function,
headers := jsonb_build_object(
'Content-Type', 'application/json',
'Authorization', 'Bearer ' || v_anon_key
),
body := jsonb_build_object('agent_instance_id', v_agent_id)
);
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS subscription_status_change_trigger ON public.subscriptions;
CREATE TRIGGER subscription_status_change_trigger
AFTER UPDATE ON public.subscriptions
FOR EACH ROW
EXECUTE FUNCTION public.trigger_suspend_or_resume_agent();