mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 12:56:51 +00:00
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:
commit
16b1210c5a
18 changed files with 570 additions and 210 deletions
|
|
@ -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>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
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>;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: {} });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
Loading…
Add table
Add a link
Reference in a new issue