chore: auto-commit (12 archivos)
- app.md - backend/handlers.go - backend/main.go - frontend/src/App.tsx - frontend/src/api.ts - frontend/vite.config.ts - backend/mcp_http.go - backend/mcp_tokens.go - backend/mcp_tokens_handlers.go - backend/migrations/016_mcp_tokens.sql - ... Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+36
-3
@@ -56,6 +56,7 @@ import {
|
||||
IconLayoutKanban,
|
||||
IconLogout,
|
||||
IconPlug,
|
||||
IconKey,
|
||||
IconMenu2,
|
||||
IconMessageChatbot,
|
||||
IconMoodSmile,
|
||||
@@ -84,6 +85,7 @@ import { AVATAR_COLORS } from "./components/colors";
|
||||
import { colorBg, colorBorder } from "./components/colors";
|
||||
import { NotificationsBell } from "./components/NotificationsBell";
|
||||
import { ModulesModal } from "./components/ModulesModal";
|
||||
import { MCPTokensModal } from "./components/MCPTokensModal";
|
||||
import { useEventStream } from "./hooks/useEventStream";
|
||||
import type { Board, Card, CardColor, Column, ColumnLocation, Notification, User } from "./types";
|
||||
|
||||
@@ -255,6 +257,23 @@ export function App() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Coalesce ráfagas de board.invalidated (trailing debounce 300ms) — sin esto
|
||||
// cada mutación remota dispara un refetch /api/board completo y la memoria
|
||||
// del navegador crece sin techo.
|
||||
const reloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const debouncedReload = useCallback(() => {
|
||||
if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
|
||||
reloadTimerRef.current = setTimeout(() => {
|
||||
reloadTimerRef.current = null;
|
||||
reload();
|
||||
}, 300);
|
||||
}, [reload]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, [reload]);
|
||||
@@ -344,6 +363,7 @@ export function App() {
|
||||
}, []);
|
||||
|
||||
const [modulesOpen, setModulesOpen] = useState(false);
|
||||
const [mcpTokensOpen, setMcpTokensOpen] = useState(false);
|
||||
|
||||
const reloadNotifs = useCallback(async () => {
|
||||
try {
|
||||
@@ -367,7 +387,7 @@ export function App() {
|
||||
useMemo(
|
||||
() => ({
|
||||
"board.invalidated": () => {
|
||||
reload();
|
||||
debouncedReload();
|
||||
},
|
||||
"notification.created": (payload: unknown) => {
|
||||
const n = payload as Notification;
|
||||
@@ -377,6 +397,7 @@ export function App() {
|
||||
const who = n.actor_name || "Alguien";
|
||||
const card = n.card_seq_num ? `#${n.card_seq_num}` : n.card_title;
|
||||
notifications.show({
|
||||
autoClose: 4000,
|
||||
color: n.kind === "mention" ? "grape" : "blue",
|
||||
title: `${who} en ${card}`,
|
||||
message: n.snippet,
|
||||
@@ -393,7 +414,7 @@ export function App() {
|
||||
setNotifUnread(0);
|
||||
},
|
||||
}),
|
||||
[reload],
|
||||
[debouncedReload],
|
||||
),
|
||||
!!auth.user,
|
||||
);
|
||||
@@ -428,16 +449,21 @@ export function App() {
|
||||
(c: Card): boolean => {
|
||||
const term = searchTerm.trim().toLowerCase();
|
||||
if (term) {
|
||||
const seqStr = c.seq_num > 0 ? String(c.seq_num) : "";
|
||||
const seqPadded = c.seq_num > 0 ? String(c.seq_num).padStart(5, "0") : "";
|
||||
const hay = [
|
||||
c.title,
|
||||
c.description,
|
||||
c.requester,
|
||||
seqStr,
|
||||
seqPadded,
|
||||
...(c.tags || []),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
if (!hay.includes(term)) return false;
|
||||
const normalizedTerm = term.replace(/^#/, "").replace(/^0+(?=\d)/, "");
|
||||
if (!hay.includes(term) && !(normalizedTerm && hay.includes(normalizedTerm))) return false;
|
||||
}
|
||||
if (filterAssigneeId && c.assignee_id !== filterAssigneeId) return false;
|
||||
if (filterUnassigned && c.assignee_id) return false;
|
||||
@@ -1266,6 +1292,12 @@ export function App() {
|
||||
Modulos
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
leftSection={<IconKey size={14} />}
|
||||
onClick={() => setMcpTokensOpen(true)}
|
||||
>
|
||||
MCP tokens
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconLogout size={14} />}
|
||||
color="red"
|
||||
@@ -1279,6 +1311,7 @@ export function App() {
|
||||
{auth.user?.is_admin && (
|
||||
<ModulesModal opened={modulesOpen} onClose={() => setModulesOpen(false)} />
|
||||
)}
|
||||
<MCPTokensModal opened={mcpTokensOpen} onClose={() => setMcpTokensOpen(false)} />
|
||||
</Group>
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
|
||||
@@ -443,6 +443,29 @@ export function listRequesters(): Promise<string[]> {
|
||||
return fetchJSON("/requesters");
|
||||
}
|
||||
|
||||
export interface MCPToken {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
last_used_at?: string;
|
||||
}
|
||||
|
||||
export interface MCPTokenCreated extends MCPToken {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export function createMCPToken(name: string): Promise<MCPTokenCreated> {
|
||||
return fetchJSON("/mcp-tokens", { method: "POST", body: JSON.stringify({ name }) });
|
||||
}
|
||||
|
||||
export function listMCPTokens(): Promise<MCPToken[]> {
|
||||
return fetchJSON("/mcp-tokens");
|
||||
}
|
||||
|
||||
export function revokeMCPToken(id: string): Promise<void> {
|
||||
return fetchJSON(`/mcp-tokens/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function getMetrics(f: MetricsFilter): Promise<Metrics> {
|
||||
const qs = new URLSearchParams();
|
||||
if (f.from) qs.set("from", f.from);
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Code,
|
||||
CopyButton,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IconCopy, IconCheck, IconTrash } from "@tabler/icons-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import * as api from "../api";
|
||||
import type { MCPToken, MCPTokenCreated } from "../api";
|
||||
import { formatDateTimeShort } from "./format";
|
||||
|
||||
interface Props {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function MCPTokensModal({ opened, onClose }: Props) {
|
||||
const [tokens, setTokens] = useState<MCPToken[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [justCreated, setJustCreated] = useState<MCPTokenCreated | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setTokens(await api.listMCPTokens());
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
reload();
|
||||
setJustCreated(null);
|
||||
setNewName("");
|
||||
}
|
||||
}, [opened, reload]);
|
||||
|
||||
const create = async () => {
|
||||
const name = newName.trim() || "default";
|
||||
setCreating(true);
|
||||
try {
|
||||
const t = await api.createMCPToken(name);
|
||||
setJustCreated(t);
|
||||
setNewName("");
|
||||
await reload();
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const revoke = async (id: string) => {
|
||||
if (!confirm("Revocar este token? Quien lo este usando dejara de tener acceso.")) return;
|
||||
try {
|
||||
await api.revokeMCPToken(id);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
const mcpURL = `${window.location.origin}/mcp`;
|
||||
const claudeCmd = justCreated
|
||||
? `claude mcp add kanban --transport http ${mcpURL} --header "Authorization: Bearer ${justCreated.token}"`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="MCP Tokens" size="lg">
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Cada token deja conectar un cliente Claude al kanban como tu usuario.
|
||||
El valor solo aparece UNA vez al crearlo. Si lo pierdes, generas otro y revocas el antiguo.
|
||||
</Text>
|
||||
|
||||
<Group align="end">
|
||||
<TextInput
|
||||
label="Nombre del token"
|
||||
placeholder="ej. portatil, sobremesa..."
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.currentTarget.value)}
|
||||
style={{ flex: 1 }}
|
||||
disabled={creating}
|
||||
/>
|
||||
<Button onClick={create} loading={creating}>
|
||||
Generar
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{justCreated && (
|
||||
<Alert color="yellow" title="Copia el token ahora — no se mostrara mas">
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs" align="center">
|
||||
<Code style={{ flex: 1, wordBreak: "break-all" }}>{justCreated.token}</Code>
|
||||
<CopyButton value={justCreated.token}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip label={copied ? "Copiado" : "Copiar token"}>
|
||||
<ActionIcon variant="subtle" onClick={copy}>
|
||||
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Group>
|
||||
<Divider />
|
||||
<Text size="xs" c="dimmed">
|
||||
Pega este comando en tu PC para registrar el MCP en Claude Code:
|
||||
</Text>
|
||||
<Group gap="xs" align="center">
|
||||
<Code block style={{ flex: 1 }}>{claudeCmd}</Code>
|
||||
<CopyButton value={claudeCmd}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip label={copied ? "Copiado" : "Copiar comando"}>
|
||||
<ActionIcon variant="subtle" onClick={copy}>
|
||||
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Divider label="Tokens activos" labelPosition="left" />
|
||||
|
||||
{loading ? (
|
||||
<Group justify="center" p="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : tokens.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
Sin tokens. Genera uno arriba.
|
||||
</Text>
|
||||
) : (
|
||||
<Table withTableBorder withColumnBorders verticalSpacing="xs" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Nombre</Table.Th>
|
||||
<Table.Th>Creado</Table.Th>
|
||||
<Table.Th>Ultimo uso</Table.Th>
|
||||
<Table.Th w={60} />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{tokens.map((t) => (
|
||||
<Table.Tr key={t.id}>
|
||||
<Table.Td>{t.name}</Table.Td>
|
||||
<Table.Td>{formatDateTimeShort(t.created_at)}</Table.Td>
|
||||
<Table.Td>
|
||||
{t.last_used_at ? formatDateTimeShort(t.last_used_at) : <Text c="dimmed">nunca</Text>}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Tooltip label="Revocar">
|
||||
<ActionIcon color="red" variant="subtle" onClick={() => revoke(t.id)}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed">
|
||||
Endpoint MCP: <Code>{mcpURL}</Code>
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user