Compare commits
15 Commits
954628a3b9
...
587dc06e8c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
587dc06e8c | ||
|
|
80e2201b8b | ||
|
|
f67c788cbe | ||
|
|
850a5392ce | ||
|
|
aef5280ae8 | ||
|
|
16b681c55b | ||
|
|
d193c6113d | ||
|
|
b5cafb9678 | ||
|
|
bf25b9ac71 | ||
|
|
da96cdd236 | ||
|
|
c41f4efc04 | ||
|
|
623faf8c55 | ||
|
|
4d4a0dc886 | ||
|
|
0a02e14bda | ||
|
|
e6a5828d14 |
@ -80,6 +80,7 @@ admin:
|
|||||||
COOKIE_SECURE: false
|
COOKIE_SECURE: false
|
||||||
COOKIE_SAME_SITE: lax
|
COOKIE_SAME_SITE: lax
|
||||||
APP_NEXTAUTH_SECRET: ""
|
APP_NEXTAUTH_SECRET: ""
|
||||||
|
ADMIN_RPC_URL: adminrpc.gitdataai.svc.cluster.local:3001
|
||||||
|
|
||||||
|
|
||||||
nodeSelector: { }
|
nodeSelector: { }
|
||||||
|
|||||||
18
admin/package-lock.json
generated
18
admin/package-lock.json
generated
@ -8,6 +8,8 @@
|
|||||||
"name": "admin",
|
"name": "admin",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@bufbuild/connect": "^0.13.0",
|
||||||
|
"@bufbuild/protobuf": "^2.11.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"argon2": "^0.44.0",
|
"argon2": "^0.44.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
@ -272,6 +274,22 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@bufbuild/connect": {
|
||||||
|
"version": "0.13.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@bufbuild/connect/-/connect-0.13.0.tgz",
|
||||||
|
"integrity": "sha512-eZSMbVLyUFtXiZNORgCEvv580xKZeYQdMOWj2i/nxOcpXQcrEzTMTA7SZzWv4k4gveWCOSRoWmYDeOhfWXJv0g==",
|
||||||
|
"deprecated": "Connect has moved to its own org @connectrpc and has a stable v1. Run `npx @connectrpc/connect-migrate@latest` to update. See https://github.com/connectrpc/connect-es/releases/tag/v0.13.1 for details.",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@bufbuild/protobuf": "^1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@bufbuild/protobuf": {
|
||||||
|
"version": "2.11.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@bufbuild/protobuf/-/protobuf-2.11.0.tgz",
|
||||||
|
"integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
|
||||||
|
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||||
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|||||||
@ -13,6 +13,8 @@
|
|||||||
"test:ui": "playwright test --ui"
|
"test:ui": "playwright test --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@bufbuild/connect": "^0.13.0",
|
||||||
|
"@bufbuild/protobuf": "^2.11.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"argon2": "^0.44.0",
|
"argon2": "^0.44.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
|
|||||||
@ -18,6 +18,7 @@ interface AiConfig {
|
|||||||
ai_model?: string;
|
ai_model?: string;
|
||||||
ai_api_key?: string;
|
ai_api_key?: string;
|
||||||
ai_enabled?: string;
|
ai_enabled?: string;
|
||||||
|
basic_api_url?: string;
|
||||||
smtp_host?: string;
|
smtp_host?: string;
|
||||||
smtp_port?: string;
|
smtp_port?: string;
|
||||||
smtp_username?: string;
|
smtp_username?: string;
|
||||||
@ -318,6 +319,15 @@ export default function DailyReportPage() {
|
|||||||
onChange={e => setAiForm(f => ({ ...f, ai_api_key: e.target.value }))}
|
onChange={e => setAiForm(f => ({ ...f, ai_api_key: e.target.value }))}
|
||||||
placeholder="sk-..." />
|
placeholder="sk-..." />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">自定义 API 地址(可选)</label>
|
||||||
|
<input className="form-input" value={aiForm.basic_api_url || ""}
|
||||||
|
onChange={e => setAiForm(f => ({ ...f, basic_api_url: e.target.value }))}
|
||||||
|
placeholder="https://api.openai.com(留空使用默认地址)" />
|
||||||
|
<span style={{ fontSize: "12px", color: "#737373" }}>
|
||||||
|
支持 OpenAI 兼容接口,如 Cloudflare AI Gateway、OneAPI 等
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SMTP Settings */}
|
{/* SMTP Settings */}
|
||||||
|
|||||||
@ -1,40 +1,48 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { RUST_BACKEND_URL, ADMIN_API_SHARED_KEY } from "@/lib/env";
|
import { createModel, updateModel, deleteModel } from "@/lib/adminrpc/client";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
async function adminFetch(path: string, method: string, body?: unknown) {
|
|
||||||
if (!ADMIN_API_SHARED_KEY) {
|
|
||||||
return NextResponse.json({ error: "ADMIN_API_SHARED_KEY 未配置" }, { status: 500 });
|
|
||||||
}
|
|
||||||
const res = await fetch(`${RUST_BACKEND_URL}${path}`, {
|
|
||||||
method,
|
|
||||||
headers: { "Content-Type": "application/json", "x-admin-api-key": ADMIN_API_SHARED_KEY },
|
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
|
||||||
signal: AbortSignal.timeout(30_000),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) return NextResponse.json(data, { status: res.status });
|
|
||||||
return NextResponse.json(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /api/admin/ai/models — create model
|
// POST /api/admin/ai/models — create model
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
return adminFetch("/api/admin/ai/models", "POST", await req.json());
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const data = await createModel(body);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error("Create model error:", msg);
|
||||||
|
return NextResponse.json({ error: `创建失败: ${msg}` }, { status: 500 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH /api/admin/ai/models?id={id} — update model
|
// PATCH /api/admin/ai/models?id={id} — update model
|
||||||
export async function PATCH(req: NextRequest) {
|
export async function PATCH(req: NextRequest) {
|
||||||
const { searchParams } = new URL(req.url);
|
try {
|
||||||
const id = searchParams.get("id");
|
const { searchParams } = new URL(req.url);
|
||||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
const id = searchParams.get("id");
|
||||||
return adminFetch(`/api/admin/ai/models/${id}`, "PATCH", await req.json());
|
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||||
|
const body = await req.json();
|
||||||
|
const data = await updateModel(id, body);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error("Update model error:", msg);
|
||||||
|
return NextResponse.json({ error: `更新失败: ${msg}` }, { status: 500 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/admin/ai/models?id={id} — delete model
|
// DELETE /api/admin/ai/models?id={id} — delete model
|
||||||
export async function DELETE(req: NextRequest) {
|
export async function DELETE(req: NextRequest) {
|
||||||
const { searchParams } = new URL(req.url);
|
try {
|
||||||
const id = searchParams.get("id");
|
const { searchParams } = new URL(req.url);
|
||||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
const id = searchParams.get("id");
|
||||||
return adminFetch(`/api/admin/ai/models/${id}`, "DELETE");
|
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||||
|
const data = await deleteModel(id);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error("Delete model error:", msg);
|
||||||
|
return NextResponse.json({ error: `删除失败: ${msg}` }, { status: 500 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { RUST_BACKEND_URL, ADMIN_API_SHARED_KEY } from "@/lib/env";
|
import { updatePricing } from "@/lib/adminrpc/client";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
@ -8,18 +8,14 @@ export async function PATCH(
|
|||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
const { id } = await params;
|
try {
|
||||||
if (!ADMIN_API_SHARED_KEY) {
|
const { id } = await params;
|
||||||
return NextResponse.json({ error: "ADMIN_API_SHARED_KEY 未配置" }, { status: 500 });
|
const body = await req.json();
|
||||||
|
const data = await updatePricing(id, body);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error("Update pricing error:", msg);
|
||||||
|
return NextResponse.json({ error: `更新失败: ${msg}` }, { status: 500 });
|
||||||
}
|
}
|
||||||
const body = await req.json();
|
|
||||||
const res = await fetch(`${RUST_BACKEND_URL}/api/admin/ai/pricing/${id}`, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json", "x-admin-api-key": ADMIN_API_SHARED_KEY },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
signal: AbortSignal.timeout(30_000),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) return NextResponse.json(data, { status: res.status });
|
|
||||||
return NextResponse.json(data);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,40 +1,48 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { RUST_BACKEND_URL, ADMIN_API_SHARED_KEY } from "@/lib/env";
|
import { createProvider, updateProvider, deleteProvider } from "@/lib/adminrpc/client";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
async function adminFetch(path: string, method: string, body?: unknown) {
|
|
||||||
if (!ADMIN_API_SHARED_KEY) {
|
|
||||||
return NextResponse.json({ error: "ADMIN_API_SHARED_KEY 未配置" }, { status: 500 });
|
|
||||||
}
|
|
||||||
const res = await fetch(`${RUST_BACKEND_URL}${path}`, {
|
|
||||||
method,
|
|
||||||
headers: { "Content-Type": "application/json", "x-admin-api-key": ADMIN_API_SHARED_KEY },
|
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
|
||||||
signal: AbortSignal.timeout(30_000),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) return NextResponse.json(data, { status: res.status });
|
|
||||||
return NextResponse.json(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /api/admin/ai/providers — create provider
|
// POST /api/admin/ai/providers — create provider
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
return adminFetch("/api/admin/ai/providers", "POST", await req.json());
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const data = await createProvider(body);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error("Create provider error:", msg);
|
||||||
|
return NextResponse.json({ error: `创建失败: ${msg}` }, { status: 500 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH /api/admin/ai/providers?id={id} — update provider
|
// PATCH /api/admin/ai/providers?id={id} — update provider
|
||||||
export async function PATCH(req: NextRequest) {
|
export async function PATCH(req: NextRequest) {
|
||||||
const { searchParams } = new URL(req.url);
|
try {
|
||||||
const id = searchParams.get("id");
|
const { searchParams } = new URL(req.url);
|
||||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
const id = searchParams.get("id");
|
||||||
return adminFetch(`/api/admin/ai/providers/${id}`, "PATCH", await req.json());
|
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||||
|
const body = await req.json();
|
||||||
|
const data = await updateProvider(id, body);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error("Update provider error:", msg);
|
||||||
|
return NextResponse.json({ error: `更新失败: ${msg}` }, { status: 500 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/admin/ai/providers?id={id} — delete provider
|
// DELETE /api/admin/ai/providers?id={id} — delete provider
|
||||||
export async function DELETE(req: NextRequest) {
|
export async function DELETE(req: NextRequest) {
|
||||||
const { searchParams } = new URL(req.url);
|
try {
|
||||||
const id = searchParams.get("id");
|
const { searchParams } = new URL(req.url);
|
||||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
const id = searchParams.get("id");
|
||||||
return adminFetch(`/api/admin/ai/providers/${id}`, "DELETE");
|
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||||
|
const data = await deleteProvider(id);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error("Delete provider error:", msg);
|
||||||
|
return NextResponse.json({ error: `删除失败: ${msg}` }, { status: 500 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,40 +1,48 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { RUST_BACKEND_URL, ADMIN_API_SHARED_KEY } from "@/lib/env";
|
import { createVersion, updateVersion, deleteVersion } from "@/lib/adminrpc/client";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
async function adminFetch(path: string, method: string, body?: unknown) {
|
|
||||||
if (!ADMIN_API_SHARED_KEY) {
|
|
||||||
return NextResponse.json({ error: "ADMIN_API_SHARED_KEY 未配置" }, { status: 500 });
|
|
||||||
}
|
|
||||||
const res = await fetch(`${RUST_BACKEND_URL}${path}`, {
|
|
||||||
method,
|
|
||||||
headers: { "Content-Type": "application/json", "x-admin-api-key": ADMIN_API_SHARED_KEY },
|
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
|
||||||
signal: AbortSignal.timeout(30_000),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) return NextResponse.json(data, { status: res.status });
|
|
||||||
return NextResponse.json(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /api/admin/ai/versions — create version
|
// POST /api/admin/ai/versions — create version
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
return adminFetch("/api/admin/ai/versions", "POST", await req.json());
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const data = await createVersion(body);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error("Create version error:", msg);
|
||||||
|
return NextResponse.json({ error: `创建失败: ${msg}` }, { status: 500 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH /api/admin/ai/versions?id={id} — update version
|
// PATCH /api/admin/ai/versions?id={id} — update version
|
||||||
export async function PATCH(req: NextRequest) {
|
export async function PATCH(req: NextRequest) {
|
||||||
const { searchParams } = new URL(req.url);
|
try {
|
||||||
const id = searchParams.get("id");
|
const { searchParams } = new URL(req.url);
|
||||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
const id = searchParams.get("id");
|
||||||
return adminFetch(`/api/admin/ai/versions/${id}`, "PATCH", await req.json());
|
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||||
|
const body = await req.json();
|
||||||
|
const data = await updateVersion(id, body);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error("Update version error:", msg);
|
||||||
|
return NextResponse.json({ error: `更新失败: ${msg}` }, { status: 500 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/admin/ai/versions?id={id} — delete version
|
// DELETE /api/admin/ai/versions?id={id} — delete version
|
||||||
export async function DELETE(req: NextRequest) {
|
export async function DELETE(req: NextRequest) {
|
||||||
const { searchParams } = new URL(req.url);
|
try {
|
||||||
const id = searchParams.get("id");
|
const { searchParams } = new URL(req.url);
|
||||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
const id = searchParams.get("id");
|
||||||
return adminFetch(`/api/admin/ai/versions/${id}`, "DELETE");
|
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||||
|
const data = await deleteVersion(id);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error("Delete version error:", msg);
|
||||||
|
return NextResponse.json({ error: `删除失败: ${msg}` }, { status: 500 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -56,7 +56,7 @@ export async function PUT(req: NextRequest) {
|
|||||||
const body = await req.json() as Record<string, string>;
|
const body = await req.json() as Record<string, string>;
|
||||||
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
|
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
|
||||||
const allowedKeys = [
|
const allowedKeys = [
|
||||||
"ai_model", "ai_api_key", "ai_enabled",
|
"ai_model", "ai_api_key", "ai_enabled", "basic_api_url",
|
||||||
"smtp_host", "smtp_port", "smtp_username", "smtp_password", "smtp_from", "smtp_tls",
|
"smtp_host", "smtp_port", "smtp_username", "smtp_password", "smtp_from", "smtp_tls",
|
||||||
"report_enabled",
|
"report_enabled",
|
||||||
];
|
];
|
||||||
|
|||||||
@ -50,6 +50,7 @@ interface AiConfig {
|
|||||||
ai_model: string;
|
ai_model: string;
|
||||||
ai_api_key: string;
|
ai_api_key: string;
|
||||||
ai_enabled: string;
|
ai_enabled: string;
|
||||||
|
basic_api_url: string;
|
||||||
smtp_host: string;
|
smtp_host: string;
|
||||||
smtp_port: string;
|
smtp_port: string;
|
||||||
smtp_username: string;
|
smtp_username: string;
|
||||||
@ -62,10 +63,11 @@ interface AiConfig {
|
|||||||
// ─── Main handler ─────────────────────────────────────────────────────────────
|
// ─── Main handler ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
// Verify cron secret (optional, set in K8s CronJob annotation)
|
// Verify cron — accept internal marker OR secret from K8s CronJob
|
||||||
|
const cronInternal = req.headers.get("x-cron-internal");
|
||||||
const cronSecret = req.headers.get("x-cron-secret");
|
const cronSecret = req.headers.get("x-cron-secret");
|
||||||
const expectedSecret = process.env.DAILY_REPORT_CRON_SECRET;
|
const expectedSecret = process.env.DAILY_REPORT_CRON_SECRET;
|
||||||
if (expectedSecret && cronSecret !== expectedSecret) {
|
if (!cronInternal && (!expectedSecret || cronSecret !== expectedSecret)) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,7 +103,12 @@ export async function POST(req: NextRequest) {
|
|||||||
// ── Generate AI summary ──────────────────────────────────────────────────
|
// ── Generate AI summary ──────────────────────────────────────────────────
|
||||||
let aiSummary = "";
|
let aiSummary = "";
|
||||||
if (cfg.ai_enabled === "true" && cfg.ai_api_key) {
|
if (cfg.ai_enabled === "true" && cfg.ai_api_key) {
|
||||||
aiSummary = await generateAiSummary(stats, cfg.ai_model || "gpt-4o-mini", cfg.ai_api_key);
|
aiSummary = await generateAiSummary(
|
||||||
|
stats,
|
||||||
|
cfg.ai_model || "gpt-4o-mini",
|
||||||
|
cfg.ai_api_key,
|
||||||
|
cfg.basic_api_url || ""
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Build email content ─────────────────────────────────────────────────
|
// ── Build email content ─────────────────────────────────────────────────
|
||||||
@ -172,17 +179,17 @@ async function collectDailyStats(): Promise<DailyStats> {
|
|||||||
),
|
),
|
||||||
// Active users today (users who sent messages)
|
// Active users today (users who sent messages)
|
||||||
query<{ count: string }>(
|
query<{ count: string }>(
|
||||||
`SELECT COUNT(DISTINCT user_id)::text as count
|
`SELECT COUNT(DISTINCT sender_id)::text as count
|
||||||
FROM room_message WHERE created_at >= $1`,
|
FROM room_message WHERE created_at >= $1`,
|
||||||
[todayStr]
|
[todayStr]
|
||||||
),
|
),
|
||||||
// Top room by message count today
|
// Top room by message count today
|
||||||
query<{ room_id: string; room_name: string; message_count: string }>(
|
query<{ room: string; room_name: string; message_count: string }>(
|
||||||
`SELECT rm.room_id, r.name as room_name, COUNT(*)::text as message_count
|
`SELECT rm.room, r.name as room_name, COUNT(*)::text as message_count
|
||||||
FROM room_message rm
|
FROM room_message rm
|
||||||
JOIN room r ON r.id = rm.room_id
|
JOIN room r ON r.id = rm.room
|
||||||
WHERE rm.created_at >= $1
|
WHERE rm.created_at >= $1
|
||||||
GROUP BY rm.room_id, r.name
|
GROUP BY rm.room, r.name
|
||||||
ORDER BY COUNT(*) DESC
|
ORDER BY COUNT(*) DESC
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[todayStr]
|
[todayStr]
|
||||||
@ -196,7 +203,7 @@ async function collectDailyStats(): Promise<DailyStats> {
|
|||||||
activeUsers: parseInt(activeUserRow.rows[0]?.count || "0", 10),
|
activeUsers: parseInt(activeUserRow.rows[0]?.count || "0", 10),
|
||||||
newCommits: 0, // filled below
|
newCommits: 0, // filled below
|
||||||
topRoom: topRoomRow.rows[0] ? {
|
topRoom: topRoomRow.rows[0] ? {
|
||||||
id: topRoomRow.rows[0].room_id,
|
id: topRoomRow.rows[0].room,
|
||||||
name: topRoomRow.rows[0].room_name,
|
name: topRoomRow.rows[0].room_name,
|
||||||
messageCount: parseInt(topRoomRow.rows[0].message_count || "0", 10),
|
messageCount: parseInt(topRoomRow.rows[0].message_count || "0", 10),
|
||||||
} : null,
|
} : null,
|
||||||
@ -221,7 +228,7 @@ async function collectDailyStats(): Promise<DailyStats> {
|
|||||||
const messages = await query<{ content: string; created_at: string }>(
|
const messages = await query<{ content: string; created_at: string }>(
|
||||||
`SELECT content, created_at::text
|
`SELECT content, created_at::text
|
||||||
FROM room_message
|
FROM room_message
|
||||||
WHERE room_id = $1 AND created_at >= $2
|
WHERE room = $1 AND created_at >= $2
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 20`,
|
LIMIT 20`,
|
||||||
[stats.topRoom.id, todayStr]
|
[stats.topRoom.id, todayStr]
|
||||||
@ -240,7 +247,8 @@ async function collectDailyStats(): Promise<DailyStats> {
|
|||||||
async function generateAiSummary(
|
async function generateAiSummary(
|
||||||
stats: DailyStats,
|
stats: DailyStats,
|
||||||
model: string,
|
model: string,
|
||||||
apiKey: string
|
apiKey: string,
|
||||||
|
basicApiUrl: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const systemPrompt = `你是一名平台运营分析师。请根据以下每日平台数据,生成一段简洁的中文总结(100-200字),分析今日平台的关键变化和亮点。注意:
|
const systemPrompt = `你是一名平台运营分析师。请根据以下每日平台数据,生成一段简洁的中文总结(100-200字),分析今日平台的关键变化和亮点。注意:
|
||||||
1. 用专业但易懂的语言
|
1. 用专业但易懂的语言
|
||||||
@ -275,8 +283,11 @@ async function generateAiSummary(
|
|||||||
${topRoomContext}
|
${topRoomContext}
|
||||||
${userMessagesSection}`;
|
${userMessagesSection}`;
|
||||||
|
|
||||||
|
const baseUrl = basicApiUrl || "https://api.openai.com";
|
||||||
|
const chatEndpoint = `${baseUrl.replace(/\/$/, "")}/v1/chat/completions`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("https://api.openai.com/v1/chat/completions", {
|
const response = await fetch(chatEndpoint, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|||||||
59
admin/src/app/api/admin/daily-report/recipients/route.ts
Normal file
59
admin/src/app/api/admin/daily-report/recipients/route.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { query } from "@/lib/db";
|
||||||
|
import { createAuditLog } from "@/lib/log";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
// GET /api/admin/daily-report/recipients — list all recipients
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const result = await query(
|
||||||
|
`SELECT id, email, name, is_active, created_at::text as created_at, created_by
|
||||||
|
FROM internal_email_recipients
|
||||||
|
ORDER BY created_at DESC`
|
||||||
|
);
|
||||||
|
return NextResponse.json({ recipients: result.rows });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[recipients] List error:", e);
|
||||||
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/admin/daily-report/recipients — add recipient
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json() as { email?: string; name?: string };
|
||||||
|
const { email, name = "" } = body;
|
||||||
|
|
||||||
|
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
return NextResponse.json({ error: "无效的邮箱地址" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO internal_email_recipients (email, name, created_by)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id, email, name, is_active, created_at::text as created_at`,
|
||||||
|
[email.toLowerCase(), name, adminUserId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await createAuditLog({
|
||||||
|
userId: adminUserId,
|
||||||
|
username: req.headers.get("x-admin-username") || "unknown",
|
||||||
|
action: "create",
|
||||||
|
resource: "internal_email_recipient",
|
||||||
|
resourceId: String(result.rows[0]?.id),
|
||||||
|
requestParams: { email },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(result.rows[0], { status: 201 });
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { code?: string };
|
||||||
|
if (err?.code === "23505") {
|
||||||
|
return NextResponse.json({ error: "该邮箱已存在" }, { status: 409 });
|
||||||
|
}
|
||||||
|
console.error("[recipients] Add error:", e);
|
||||||
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -20,8 +20,8 @@ export async function PATCH(
|
|||||||
return NextResponse.json({ error: "无效的角色" }, { status: 400 });
|
return NextResponse.json({ error: "无效的角色" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const member = await query<{ id: string; scope: string; user: string }>(
|
const member = await query<{ id: string; scope: string; user_uuid: string }>(
|
||||||
`SELECT id, scope, user FROM project_members WHERE id = $1 AND project = $2`,
|
`SELECT id, scope, user_uuid FROM project_members WHERE id = $1 AND project_uuid = $2`,
|
||||||
[memberId, id]
|
[memberId, id]
|
||||||
);
|
);
|
||||||
if (!member.rows.length) {
|
if (!member.rows.length) {
|
||||||
@ -64,8 +64,8 @@ export async function DELETE(
|
|||||||
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
|
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
|
||||||
const adminUsername = req.headers.get("x-admin-username") || "unknown";
|
const adminUsername = req.headers.get("x-admin-username") || "unknown";
|
||||||
|
|
||||||
const member = await query<{ id: string; scope: string; user: string }>(
|
const member = await query<{ id: string; scope: string; user_uuid: string }>(
|
||||||
`SELECT id, scope, user FROM project_members WHERE id = $1 AND project = $2`,
|
`SELECT id, scope, user_uuid FROM project_members WHERE id = $1 AND project_uuid = $2`,
|
||||||
[memberId, id]
|
[memberId, id]
|
||||||
);
|
);
|
||||||
if (!member.rows.length) {
|
if (!member.rows.length) {
|
||||||
@ -83,7 +83,7 @@ export async function DELETE(
|
|||||||
action: "delete",
|
action: "delete",
|
||||||
resource: "project_member",
|
resource: "project_member",
|
||||||
resourceId: memberId,
|
resourceId: memberId,
|
||||||
requestParams: { projectId: id, userId: member.rows[0].user },
|
requestParams: { projectId: id, userId: member.rows[0].user_uuid },
|
||||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
||||||
userAgent: req.headers.get("user-agent") || undefined,
|
userAgent: req.headers.get("user-agent") || undefined,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -13,21 +13,21 @@ export async function GET(
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`SELECT pm.id, pm.project, pm.user, pm.scope, pm.joined_at::text,
|
`SELECT pm.id, pm.project_uuid, pm.user_uuid, pm.scope, pm.joined_at::text,
|
||||||
u.username, u.display_name, u.avatar_url,
|
u.username, u.display_name, u.avatar_url,
|
||||||
COALESCE(up.is_active, true) as user_is_active
|
COALESCE(up.is_active, true) as user_is_active
|
||||||
FROM project_members pm
|
FROM project_members pm
|
||||||
JOIN "user" u ON u.uid = pm.user
|
JOIN "user" u ON u.uid = pm.user_uuid
|
||||||
LEFT JOIN user_password up ON up.user = u.uid
|
LEFT JOIN user_password up ON up.user = u.uid
|
||||||
WHERE pm.project = $1
|
WHERE pm.project_uuid = $1
|
||||||
ORDER BY pm.scope = 'owner' DESC, pm.scope = 'admin' DESC, pm.joined_at ASC`,
|
ORDER BY pm.scope = 'owner' DESC, pm.scope = 'admin' DESC, pm.joined_at ASC`,
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
|
|
||||||
const members = result.rows.map((r: Record<string, unknown>) => ({
|
const members = result.rows.map((r: Record<string, unknown>) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
projectId: r.project,
|
projectId: r.project_uuid,
|
||||||
userId: r.user,
|
userId: r.user_uuid,
|
||||||
scope: r.scope,
|
scope: r.scope,
|
||||||
joinedAt: r.joined_at,
|
joinedAt: r.joined_at,
|
||||||
username: r.username,
|
username: r.username,
|
||||||
@ -81,7 +81,7 @@ export async function POST(
|
|||||||
|
|
||||||
// 检查是否已是成员
|
// 检查是否已是成员
|
||||||
const exist = await query(
|
const exist = await query(
|
||||||
`SELECT id FROM project_members WHERE project = $1 AND user = $2`,
|
`SELECT id FROM project_members WHERE project_uuid = $1 AND user_uuid = $2`,
|
||||||
[id, body.userId]
|
[id, body.userId]
|
||||||
);
|
);
|
||||||
if (exist.rows.length) {
|
if (exist.rows.length) {
|
||||||
@ -89,7 +89,7 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`INSERT INTO project_members (project, user, scope, joined_at)
|
`INSERT INTO project_members (project_uuid, user_uuid, scope, joined_at)
|
||||||
VALUES ($1, $2, $3, NOW())
|
VALUES ($1, $2, $3, NOW())
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[id, body.userId, scope]
|
[id, body.userId, scope]
|
||||||
|
|||||||
@ -8,41 +8,38 @@ export async function GET(req: NextRequest) {
|
|||||||
const { searchParams } = req.nextUrl;
|
const { searchParams } = req.nextUrl;
|
||||||
const type = searchParams.get("type") || "all";
|
const type = searchParams.get("type") || "all";
|
||||||
|
|
||||||
const providers = query(
|
const [providersData, modelsData, pricingData, versionsData] = await Promise.all([
|
||||||
`SELECT id, name, display_name, website, status, created_at
|
query(
|
||||||
FROM ai_model_provider
|
`SELECT id, name, display_name, website, status, created_at, updated_at
|
||||||
ORDER BY name`
|
FROM ai_model_provider
|
||||||
);
|
ORDER BY name`
|
||||||
|
),
|
||||||
const models = query(
|
query(
|
||||||
`SELECT m.id, m.name, m.modality, m.capability, m.context_length,
|
`SELECT m.id, m.name, m.modality, m.capability, m.context_length,
|
||||||
m.max_output_tokens, m.training_cutoff, m.is_open_source, m.status,
|
m.max_output_tokens, m.training_cutoff, m.is_open_source, m.status,
|
||||||
mv.model_id, mv.version,
|
mv.model_id, mv.version,
|
||||||
p.id as provider_id, p.name as provider_name
|
p.id as provider_id, p.name as provider_name
|
||||||
FROM ai_model m
|
FROM ai_model m
|
||||||
JOIN ai_model_provider p ON p.id = m.provider_id
|
JOIN ai_model_provider p ON p.id = m.provider_id
|
||||||
LEFT JOIN ai_model_version mv ON mv.model_id = m.id AND mv.is_default = true
|
LEFT JOIN ai_model_version mv ON mv.model_id = m.id AND mv.is_default = true
|
||||||
ORDER BY p.name, m.name`
|
ORDER BY p.name, m.name`
|
||||||
);
|
),
|
||||||
|
query(
|
||||||
const pricing = query(
|
`SELECT mp.id, mp.model_version_id, mp.input_price_per_1k_tokens, mp.output_price_per_1k_tokens,
|
||||||
`SELECT mp.id, mp.model_version_id, mp.input_price_per_1k_tokens, mp.output_price_per_1k_tokens,
|
mp.currency, mp.effective_from,
|
||||||
mp.currency, mp.effective_from,
|
m.name as model_name, mv.model_id
|
||||||
m.name as model_name, mv.model_id
|
FROM ai_model_pricing mp
|
||||||
FROM ai_model_pricing mp
|
JOIN ai_model_version mv ON mv.id = mp.model_version_id
|
||||||
JOIN ai_model_version mv ON mv.id = mp.model_version_id
|
JOIN ai_model m ON m.id = mv.model_id
|
||||||
JOIN ai_model m ON m.id = mv.model_id
|
ORDER BY mp.effective_from DESC
|
||||||
ORDER BY mp.effective_from DESC
|
LIMIT 200`
|
||||||
LIMIT 200`
|
),
|
||||||
);
|
query(
|
||||||
|
`SELECT mv.id, mv.model_id, mv.version, mv.release_date, mv.change_log, mv.is_default, mv.status, mv.created_at
|
||||||
const versions = query(
|
FROM ai_model_version mv
|
||||||
`SELECT mv.id, mv.model_id, mv.version, mv.release_date, mv.change_log, mv.is_default, mv.status, mv.created_at
|
ORDER BY mv.model_id, mv.version`
|
||||||
FROM ai_model_version mv
|
),
|
||||||
ORDER BY mv.model_id, mv.version`
|
]);
|
||||||
);
|
|
||||||
|
|
||||||
const [providersData, modelsData, pricingData, versionsData] = await Promise.all([providers, models, pricing, versions]);
|
|
||||||
|
|
||||||
const providersList = providersData.rows.map((r: Record<string, unknown>) => ({
|
const providersList = providersData.rows.map((r: Record<string, unknown>) => ({
|
||||||
id: String(r.id),
|
id: String(r.id),
|
||||||
|
|||||||
@ -1,48 +1,18 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { RUST_BACKEND_URL, ADMIN_API_SHARED_KEY } from "@/lib/env";
|
import { syncModels } from "@/lib/adminrpc/client";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trigger AI model sync via Rust backend.
|
* Trigger AI model sync via adminrpc gRPC.
|
||||||
* Calls POST /api/admin/ai/sync on the Rust app.
|
|
||||||
*/
|
*/
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
if (!ADMIN_API_SHARED_KEY) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "ADMIN_API_SHARED_KEY 未配置" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `${RUST_BACKEND_URL}/api/admin/ai/sync`;
|
const data = await syncModels();
|
||||||
const res = await fetch(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"x-admin-api-key": ADMIN_API_SHARED_KEY,
|
|
||||||
},
|
|
||||||
// Timeout: 2 minutes for sync
|
|
||||||
signal: AbortSignal.timeout(120_000),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.text();
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `同步失败: ${res.status} ${body}` },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
return NextResponse.json(data);
|
return NextResponse.json(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
console.error("AI sync error:", msg);
|
console.error("AI sync error:", msg);
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: `同步失败: ${msg}` }, { status: 500 });
|
||||||
{ error: `同步失败: ${msg}` },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,47 +1,18 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { RUST_BACKEND_URL, ADMIN_API_SHARED_KEY } from "@/lib/env";
|
import { checkAlerts } from "@/lib/adminrpc/client";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trigger workspace billing alert check via Rust backend.
|
* Trigger workspace billing alert check via adminrpc gRPC.
|
||||||
* Calls POST /api/admin/alerts/check on the Rust app.
|
|
||||||
*/
|
*/
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
if (!ADMIN_API_SHARED_KEY) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "ADMIN_API_SHARED_KEY 未配置" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `${RUST_BACKEND_URL}/api/admin/alerts/check`;
|
const data = await checkAlerts();
|
||||||
const res = await fetch(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"x-admin-api-key": ADMIN_API_SHARED_KEY,
|
|
||||||
},
|
|
||||||
signal: AbortSignal.timeout(60_000),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.text();
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `检查失败: ${res.status} ${body}` },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
return NextResponse.json(data);
|
return NextResponse.json(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
console.error("Alert check error:", msg);
|
console.error("Alert check error:", msg);
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: `检查失败: ${msg}` }, { status: 500 });
|
||||||
{ error: `检查失败: ${msg}` },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,71 +23,65 @@ export async function GET(req: NextRequest) {
|
|||||||
const action = searchParams.get("action") || "";
|
const action = searchParams.get("action") || "";
|
||||||
const offset = (page - 1) * pageSize;
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
// Build queries with proper parameter indexing
|
const actionPattern = action ? `%${action}%` : null;
|
||||||
|
const limitOffsetParams: unknown[] = [pageSize, offset];
|
||||||
|
|
||||||
let userQuery = "";
|
let userQuery = "";
|
||||||
let projectQuery = "";
|
let projectQuery = "";
|
||||||
let queryParams: unknown[] = [];
|
let userParams: unknown[] = [];
|
||||||
let userCountQuery = "";
|
let projectParams: unknown[] = [];
|
||||||
let projectCountQuery = "";
|
|
||||||
let paramIdx = 1;
|
|
||||||
|
|
||||||
|
// Build user_activity_log query
|
||||||
if (source !== "project") {
|
if (source !== "project") {
|
||||||
if (action) {
|
if (action) {
|
||||||
|
userParams = [actionPattern, ...limitOffsetParams];
|
||||||
userQuery = `SELECT 'user_activity' as source, id,
|
userQuery = `SELECT 'user_activity' as source, id,
|
||||||
COALESCE(user_uid::text, '') as actor_uid,
|
COALESCE(user_uid::text, '') as actor_uid,
|
||||||
action, NULL::text as resource,
|
action, NULL::text as resource,
|
||||||
ip_address, user_agent, created_at::text as created_at
|
ip_address, user_agent, created_at::text as created_at
|
||||||
FROM user_activity_log
|
FROM user_activity_log
|
||||||
WHERE action ILIKE $${paramIdx}
|
WHERE action ILIKE $1
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT $${paramIdx + 1} OFFSET $${paramIdx + 2}`;
|
LIMIT $2 OFFSET $3`;
|
||||||
userCountQuery = `SELECT COUNT(*) FROM user_activity_log WHERE action ILIKE $${paramIdx}`;
|
|
||||||
queryParams.push(`%${action}%`, pageSize, offset);
|
|
||||||
paramIdx += 3;
|
|
||||||
} else {
|
} else {
|
||||||
|
userParams = limitOffsetParams;
|
||||||
userQuery = `SELECT 'user_activity' as source, id,
|
userQuery = `SELECT 'user_activity' as source, id,
|
||||||
COALESCE(user_uid::text, '') as actor_uid,
|
COALESCE(user_uid::text, '') as actor_uid,
|
||||||
action, NULL::text as resource,
|
action, NULL::text as resource,
|
||||||
ip_address, user_agent, created_at::text as created_at
|
ip_address, user_agent, created_at::text as created_at
|
||||||
FROM user_activity_log
|
FROM user_activity_log
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`;
|
LIMIT $1 OFFSET $2`;
|
||||||
userCountQuery = `SELECT COUNT(*) FROM user_activity_log`;
|
|
||||||
queryParams.push(pageSize, offset);
|
|
||||||
paramIdx += 2;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build project_audit_log query
|
||||||
if (source !== "user") {
|
if (source !== "user") {
|
||||||
if (action) {
|
if (action) {
|
||||||
|
projectParams = [actionPattern, ...limitOffsetParams];
|
||||||
projectQuery = `SELECT 'project_audit' as source, id,
|
projectQuery = `SELECT 'project_audit' as source, id,
|
||||||
COALESCE(actor::text, '') as actor_uid,
|
COALESCE(actor::text, '') as actor_uid,
|
||||||
action, details as resource, ip_address,
|
action, details as resource, ip_address,
|
||||||
NULL as user_agent, created_at::text as created_at
|
NULL as user_agent, created_at::text as created_at
|
||||||
FROM project_audit_log
|
FROM project_audit_log
|
||||||
WHERE action ILIKE $${paramIdx}
|
WHERE action ILIKE $1
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT $${paramIdx + 1} OFFSET $${paramIdx + 2}`;
|
LIMIT $2 OFFSET $3`;
|
||||||
projectCountQuery = `SELECT COUNT(*) FROM project_audit_log WHERE action ILIKE $${paramIdx}`;
|
|
||||||
queryParams.push(`%${action}%`, pageSize, offset);
|
|
||||||
paramIdx += 3;
|
|
||||||
} else {
|
} else {
|
||||||
|
projectParams = limitOffsetParams;
|
||||||
projectQuery = `SELECT 'project_audit' as source, id,
|
projectQuery = `SELECT 'project_audit' as source, id,
|
||||||
COALESCE(actor::text, '') as actor_uid,
|
COALESCE(actor::text, '') as actor_uid,
|
||||||
action, details as resource, ip_address,
|
action, details as resource, ip_address,
|
||||||
NULL as user_agent, created_at::text as created_at
|
NULL as user_agent, created_at::text as created_at
|
||||||
FROM project_audit_log
|
FROM project_audit_log
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`;
|
LIMIT $1 OFFSET $2`;
|
||||||
projectCountQuery = `SELECT COUNT(*) FROM project_audit_log`;
|
|
||||||
queryParams.push(pageSize, offset);
|
|
||||||
paramIdx += 2;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [userLogs, projectLogs] = await Promise.all([
|
const [userLogs, projectLogs] = await Promise.all([
|
||||||
userQuery ? query<AuditLog>(userQuery, queryParams) : Promise.resolve({ rows: [] as AuditLog[] }),
|
userQuery ? query<AuditLog>(userQuery, userParams) : Promise.resolve({ rows: [] as AuditLog[] }),
|
||||||
projectQuery ? query<AuditLog>(projectQuery, queryParams) : Promise.resolve({ rows: [] as AuditLog[] }),
|
projectQuery ? query<AuditLog>(projectQuery, projectParams) : Promise.resolve({ rows: [] as AuditLog[] }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 合并并排序
|
// 合并并排序
|
||||||
@ -97,8 +91,8 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
// 总数
|
// 总数
|
||||||
const [userCountRes, projectCountRes] = await Promise.all([
|
const [userCountRes, projectCountRes] = await Promise.all([
|
||||||
userCountQuery ? query<{ count: string }>(userCountQuery, action ? [`%${action}%`] : []) : Promise.resolve({ rows: [{ count: "0" }] }),
|
userCountQuery(userParams, action),
|
||||||
projectCountQuery ? query<{ count: string }>(projectCountQuery, action ? [`%${action}%`] : []) : Promise.resolve({ rows: [{ count: "0" }] }),
|
projectCountQuery(projectParams, action),
|
||||||
]);
|
]);
|
||||||
const total = parseInt(String(userCountRes.rows[0]?.count || "0"), 10) +
|
const total = parseInt(String(userCountRes.rows[0]?.count || "0"), 10) +
|
||||||
parseInt(String(projectCountRes.rows[0]?.count || "0"), 10);
|
parseInt(String(projectCountRes.rows[0]?.count || "0"), 10);
|
||||||
@ -109,3 +103,25 @@ export async function GET(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function userCountQuery(params: unknown[], action: string | null) {
|
||||||
|
if (!params.length) return { rows: [] as { count: string }[] };
|
||||||
|
if (action) {
|
||||||
|
return query<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) FROM user_activity_log WHERE action ILIKE $1`,
|
||||||
|
[params[0]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return query<{ count: string }>(`SELECT COUNT(*) FROM user_activity_log`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function projectCountQuery(params: unknown[], action: string | null) {
|
||||||
|
if (!params.length) return { rows: [] as { count: string }[] };
|
||||||
|
if (action) {
|
||||||
|
return query<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) FROM project_audit_log WHERE action ILIKE $1`,
|
||||||
|
[params[0]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return query<{ count: string }>(`SELECT COUNT(*) FROM project_audit_log`);
|
||||||
|
}
|
||||||
|
|||||||
@ -79,20 +79,20 @@ export async function PATCH(req: NextRequest) {
|
|||||||
|
|
||||||
// Get user ids from uids
|
// Get user ids from uids
|
||||||
const uidPlaceholders = ids.map((_, i) => `$${i + 1}`).join(", ");
|
const uidPlaceholders = ids.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
const uidResult = await query<{ id: number }>(
|
const uidResult = await query<{ uid: string }>(
|
||||||
`SELECT id FROM "user" WHERE uid IN (${uidPlaceholders})`,
|
`SELECT uid FROM "user" WHERE uid IN (${uidPlaceholders})`,
|
||||||
ids
|
ids
|
||||||
);
|
);
|
||||||
const userIds = uidResult.rows.map((r) => r.id);
|
const uids = uidResult.rows.map((r) => r.uid);
|
||||||
|
|
||||||
if (!userIds.length) {
|
if (!uids.length) {
|
||||||
return NextResponse.json({ error: "未找到匹配的用户" }, { status: 404 });
|
return NextResponse.json({ error: "未找到匹配的用户" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const idPlaceholders = userIds.map((_, i) => `$${i + 1}`).join(", ");
|
const uidPlaceholders2 = uids.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
await query(
|
await query(
|
||||||
`UPDATE user_password SET is_active = $${userIds.length + 1}, updated_at = NOW() WHERE user_id IN (${idPlaceholders})`,
|
`UPDATE user_password SET is_active = $${uids.length + 1}, updated_at = NOW() WHERE "user" IN (${uidPlaceholders2})`,
|
||||||
[...userIds, isActive]
|
[...uids, isActive]
|
||||||
);
|
);
|
||||||
|
|
||||||
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
|
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
|
||||||
@ -102,13 +102,13 @@ export async function PATCH(req: NextRequest) {
|
|||||||
username: adminUsername,
|
username: adminUsername,
|
||||||
action: "update",
|
action: "update",
|
||||||
resource: "user_batch_status",
|
resource: "user_batch_status",
|
||||||
resourceId: `batch(${userIds.length})`,
|
resourceId: `batch(${uids.length})`,
|
||||||
requestParams: { uidCount: ids.length, userIdCount: userIds.length, action },
|
requestParams: { uidCount: ids.length, userIdCount: uids.length, action },
|
||||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
||||||
userAgent: req.headers.get("user-agent") || undefined,
|
userAgent: req.headers.get("user-agent") || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ success: true, updated: userIds.length });
|
return NextResponse.json({ success: true, updated: uids.length });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Batch update user status error:", e);
|
console.error("Batch update user status error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
|
|||||||
@ -66,7 +66,7 @@ export async function POST(
|
|||||||
// Insert billing history
|
// Insert billing history
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO workspace_billing_history
|
`INSERT INTO workspace_billing_history
|
||||||
(workspace_id, user, amount, reason, extra, currency)
|
(workspace_id, user_id, amount, reason, extra, currency)
|
||||||
VALUES ($1, NULL, $2, 'admin_credit', $3, $4)`,
|
VALUES ($1, NULL, $2, 'admin_credit', $3, $4)`,
|
||||||
[id, amount, JSON.stringify({ description: description || "Admin 手动充值" }), currency]
|
[id, amount, JSON.stringify({ description: description || "Admin 手动充值" }), currency]
|
||||||
);
|
);
|
||||||
|
|||||||
270
admin/src/lib/adminrpc/client.ts
Normal file
270
admin/src/lib/adminrpc/client.ts
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
/**
|
||||||
|
* AdminRPC gRPC client for Node.js environment.
|
||||||
|
*
|
||||||
|
* Uses raw HTTP/1.1 with the tonic gRPC server.
|
||||||
|
* All methods pass body_json as a JSON string (matching the proto design).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create, toBinary, fromBinary } from "@bufbuild/protobuf";
|
||||||
|
import type { IncomingMessage } from "node:http";
|
||||||
|
import { ADMIN_RPC_URL } from "@/lib/env";
|
||||||
|
|
||||||
|
// Import schemas from generated proto
|
||||||
|
import * as admin from "./generated/proto/admin_pb";
|
||||||
|
|
||||||
|
// ─── Low-level gRPC-Web over HTTP/1.1 ────────────────────────────────────────
|
||||||
|
|
||||||
|
function grpcRequest(
|
||||||
|
baseUrl: string,
|
||||||
|
servicePath: string,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
schema: any,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
responseSchema: any,
|
||||||
|
body: Uint8Array,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = new URL(baseUrl);
|
||||||
|
const isHttps = url.protocol === "https:";
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
const http = (isHttps ? require("node:https") : require("node:http")) as typeof import("node:http");
|
||||||
|
|
||||||
|
const req = http.request(
|
||||||
|
{
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port || (isHttps ? 443 : 80),
|
||||||
|
path: servicePath,
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/grpc-web+proto",
|
||||||
|
"X-Grpc-Web": "1",
|
||||||
|
"TE": "trailers",
|
||||||
|
"User-Agent": "admin-module/1.0",
|
||||||
|
"Content-Length": body.byteLength,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(res: IncomingMessage) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
res.on("end", () => {
|
||||||
|
const data = Buffer.concat(chunks);
|
||||||
|
if (data.length === 0) {
|
||||||
|
const grpcStatus = res.headers["grpc-status"];
|
||||||
|
if (grpcStatus && grpcStatus !== "0") {
|
||||||
|
reject(new Error(`gRPC error ${grpcStatus}: ${res.headers["grpc-message"] || ""}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(new Uint8Array(0));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// gRPC-web body format: [0x00, 4-byte big-endian length, payload...]
|
||||||
|
if (data[0] === 0x00 && data.length > 5) {
|
||||||
|
const bodyBytes = data.slice(5);
|
||||||
|
resolve(new Uint8Array(bodyBytes.buffer, bodyBytes.byteOffset, bodyBytes.byteLength));
|
||||||
|
} else {
|
||||||
|
resolve(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
req.on("error", reject);
|
||||||
|
req.write(body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Message encoding / decoding ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function encode(schema: any, init: Record<string, unknown>): Uint8Array {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const msg = create(schema as any, init as any);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return toBinary(schema as any, msg as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function decode(schema: any, bytes: Uint8Array): any {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return fromBinary(schema as any, bytes) as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── AI: Sync Models ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function syncModels(): Promise<unknown> {
|
||||||
|
const body = encode(admin.SyncModelsRequestSchema, {});
|
||||||
|
const bytes = await grpcRequest(
|
||||||
|
ADMIN_RPC_URL,
|
||||||
|
"/admin.SessionAdmin/SyncModels",
|
||||||
|
admin.SyncModelsRequestSchema,
|
||||||
|
admin.SyncModelsResponseSchema,
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
if (bytes.length === 0) return {};
|
||||||
|
const resp = decode(admin.SyncModelsResponseSchema, bytes);
|
||||||
|
return JSON.parse(resp.bodyJson || "{}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── AI: Check Alerts ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function checkAlerts(): Promise<unknown> {
|
||||||
|
const body = encode(admin.CheckAlertsRequestSchema, {});
|
||||||
|
const bytes = await grpcRequest(
|
||||||
|
ADMIN_RPC_URL,
|
||||||
|
"/admin.SessionAdmin/CheckAlerts",
|
||||||
|
admin.CheckAlertsRequestSchema,
|
||||||
|
admin.CheckAlertsResponseSchema,
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
if (bytes.length === 0) return {};
|
||||||
|
const resp = decode(admin.CheckAlertsResponseSchema, bytes);
|
||||||
|
return JSON.parse(resp.bodyJson || "{}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── AI: Provider CRUD ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function createProvider(body: unknown): Promise<unknown> {
|
||||||
|
const msgBody = encode(admin.CreateProviderRequestSchema, { bodyJson: JSON.stringify(body) });
|
||||||
|
const bytes = await grpcRequest(
|
||||||
|
ADMIN_RPC_URL,
|
||||||
|
"/admin.SessionAdmin/CreateProvider",
|
||||||
|
admin.CreateProviderRequestSchema,
|
||||||
|
admin.ProviderResponseSchema,
|
||||||
|
msgBody,
|
||||||
|
);
|
||||||
|
if (bytes.length === 0) return {};
|
||||||
|
const resp = decode(admin.ProviderResponseSchema, bytes);
|
||||||
|
return JSON.parse(resp.bodyJson || "{}");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProvider(id: string, body: unknown): Promise<unknown> {
|
||||||
|
const msgBody = encode(admin.UpdateProviderRequestSchema, { id, bodyJson: JSON.stringify(body) });
|
||||||
|
const bytes = await grpcRequest(
|
||||||
|
ADMIN_RPC_URL,
|
||||||
|
"/admin.SessionAdmin/UpdateProvider",
|
||||||
|
admin.UpdateProviderRequestSchema,
|
||||||
|
admin.ProviderResponseSchema,
|
||||||
|
msgBody,
|
||||||
|
);
|
||||||
|
if (bytes.length === 0) return {};
|
||||||
|
const resp = decode(admin.ProviderResponseSchema, bytes);
|
||||||
|
return JSON.parse(resp.bodyJson || "{}");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProvider(id: string): Promise<{ deleted: boolean }> {
|
||||||
|
const msgBody = encode(admin.DeleteProviderRequestSchema, { id });
|
||||||
|
const bytes = await grpcRequest(
|
||||||
|
ADMIN_RPC_URL,
|
||||||
|
"/admin.SessionAdmin/DeleteProvider",
|
||||||
|
admin.DeleteProviderRequestSchema,
|
||||||
|
admin.DeleteResponseSchema,
|
||||||
|
msgBody,
|
||||||
|
);
|
||||||
|
if (bytes.length === 0) return { deleted: false };
|
||||||
|
const resp = decode(admin.DeleteResponseSchema, bytes);
|
||||||
|
return { deleted: resp.deleted };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── AI: Model CRUD ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function createModel(body: unknown): Promise<unknown> {
|
||||||
|
const msgBody = encode(admin.CreateModelRequestSchema, { bodyJson: JSON.stringify(body) });
|
||||||
|
const bytes = await grpcRequest(
|
||||||
|
ADMIN_RPC_URL,
|
||||||
|
"/admin.SessionAdmin/CreateModel",
|
||||||
|
admin.CreateModelRequestSchema,
|
||||||
|
admin.ModelResponseSchema,
|
||||||
|
msgBody,
|
||||||
|
);
|
||||||
|
if (bytes.length === 0) return {};
|
||||||
|
const resp = decode(admin.ModelResponseSchema, bytes);
|
||||||
|
return JSON.parse(resp.bodyJson || "{}");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateModel(id: string, body: unknown): Promise<unknown> {
|
||||||
|
const msgBody = encode(admin.UpdateModelRequestSchema, { id, bodyJson: JSON.stringify(body) });
|
||||||
|
const bytes = await grpcRequest(
|
||||||
|
ADMIN_RPC_URL,
|
||||||
|
"/admin.SessionAdmin/UpdateModel",
|
||||||
|
admin.UpdateModelRequestSchema,
|
||||||
|
admin.ModelResponseSchema,
|
||||||
|
msgBody,
|
||||||
|
);
|
||||||
|
if (bytes.length === 0) return {};
|
||||||
|
const resp = decode(admin.ModelResponseSchema, bytes);
|
||||||
|
return JSON.parse(resp.bodyJson || "{}");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteModel(id: string): Promise<{ deleted: boolean }> {
|
||||||
|
const msgBody = encode(admin.DeleteModelRequestSchema, { id });
|
||||||
|
const bytes = await grpcRequest(
|
||||||
|
ADMIN_RPC_URL,
|
||||||
|
"/admin.SessionAdmin/DeleteModel",
|
||||||
|
admin.DeleteModelRequestSchema,
|
||||||
|
admin.DeleteResponseSchema,
|
||||||
|
msgBody,
|
||||||
|
);
|
||||||
|
if (bytes.length === 0) return { deleted: false };
|
||||||
|
const resp = decode(admin.DeleteResponseSchema, bytes);
|
||||||
|
return { deleted: resp.deleted };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── AI: Version CRUD ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function createVersion(body: unknown): Promise<unknown> {
|
||||||
|
const msgBody = encode(admin.CreateVersionRequestSchema, { bodyJson: JSON.stringify(body) });
|
||||||
|
const bytes = await grpcRequest(
|
||||||
|
ADMIN_RPC_URL,
|
||||||
|
"/admin.SessionAdmin/CreateVersion",
|
||||||
|
admin.CreateVersionRequestSchema,
|
||||||
|
admin.VersionResponseSchema,
|
||||||
|
msgBody,
|
||||||
|
);
|
||||||
|
if (bytes.length === 0) return {};
|
||||||
|
const resp = decode(admin.VersionResponseSchema, bytes);
|
||||||
|
return JSON.parse(resp.bodyJson || "{}");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateVersion(id: string, body: unknown): Promise<unknown> {
|
||||||
|
const msgBody = encode(admin.UpdateVersionRequestSchema, { id, bodyJson: JSON.stringify(body) });
|
||||||
|
const bytes = await grpcRequest(
|
||||||
|
ADMIN_RPC_URL,
|
||||||
|
"/admin.SessionAdmin/UpdateVersion",
|
||||||
|
admin.UpdateVersionRequestSchema,
|
||||||
|
admin.VersionResponseSchema,
|
||||||
|
msgBody,
|
||||||
|
);
|
||||||
|
if (bytes.length === 0) return {};
|
||||||
|
const resp = decode(admin.VersionResponseSchema, bytes);
|
||||||
|
return JSON.parse(resp.bodyJson || "{}");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteVersion(id: string): Promise<{ deleted: boolean }> {
|
||||||
|
const msgBody = encode(admin.DeleteVersionRequestSchema, { id });
|
||||||
|
const bytes = await grpcRequest(
|
||||||
|
ADMIN_RPC_URL,
|
||||||
|
"/admin.SessionAdmin/DeleteVersion",
|
||||||
|
admin.DeleteVersionRequestSchema,
|
||||||
|
admin.DeleteResponseSchema,
|
||||||
|
msgBody,
|
||||||
|
);
|
||||||
|
if (bytes.length === 0) return { deleted: false };
|
||||||
|
const resp = decode(admin.DeleteResponseSchema, bytes);
|
||||||
|
return { deleted: resp.deleted };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── AI: Pricing Update ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function updatePricing(id: string, body: unknown): Promise<unknown> {
|
||||||
|
const msgBody = encode(admin.UpdatePricingRequestSchema, { id, bodyJson: JSON.stringify(body) });
|
||||||
|
const bytes = await grpcRequest(
|
||||||
|
ADMIN_RPC_URL,
|
||||||
|
"/admin.SessionAdmin/UpdatePricing",
|
||||||
|
admin.UpdatePricingRequestSchema,
|
||||||
|
admin.PricingResponseSchema,
|
||||||
|
msgBody,
|
||||||
|
);
|
||||||
|
if (bytes.length === 0) return {};
|
||||||
|
const resp = decode(admin.PricingResponseSchema, bytes);
|
||||||
|
return JSON.parse(resp.bodyJson || "{}");
|
||||||
|
}
|
||||||
225
admin/src/lib/adminrpc/generated/proto/admin_connect.d.ts
vendored
Normal file
225
admin/src/lib/adminrpc/generated/proto/admin_connect.d.ts
vendored
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
// @generated by protoc-gen-connect-es v0.13.0
|
||||||
|
// @generated from file proto/admin.proto (package admin, syntax proto3)
|
||||||
|
/* eslint-disable */
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
import { CheckAlertsRequest, CheckAlertsResponse, CreateModelRequest, CreateProviderRequest, CreateVersionRequest, DeleteModelRequest, DeleteProviderRequest, DeleteResponse, DeleteVersionRequest, ExportMetricsCsvRequest, ExportMetricsCsvResponse, GetMetricsRequest, GetMetricsResponse, GetUserInfoRequest, GetUserInfoResponse, GetUserStatusRequest, GetUserStatusResponse, GetWorkspaceOnlineUsersRequest, GetWorkspaceOnlineUsersResponse, IsUserOnlineRequest, IsUserOnlineResponse, KickUserFromWorkspaceRequest, KickUserFromWorkspaceResponse, KickUserRequest, KickUserResponse, ListUserSessionsRequest, ListUserSessionsResponse, ListWorkspaceSessionsRequest, ListWorkspaceSessionsResponse, ModelResponse, PricingResponse, ProviderResponse, SyncModelsRequest, SyncModelsResponse, UpdateModelRequest, UpdatePricingRequest, UpdateProviderRequest, UpdateVersionRequest, VersionResponse } from "./admin_pb.js";
|
||||||
|
import { MethodKind } from "@bufbuild/protobuf";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from service admin.SessionAdmin
|
||||||
|
*/
|
||||||
|
export declare const SessionAdmin: {
|
||||||
|
readonly typeName: "admin.SessionAdmin",
|
||||||
|
readonly methods: {
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.ListWorkspaceSessions
|
||||||
|
*/
|
||||||
|
readonly listWorkspaceSessions: {
|
||||||
|
readonly name: "ListWorkspaceSessions",
|
||||||
|
readonly I: typeof ListWorkspaceSessionsRequest,
|
||||||
|
readonly O: typeof ListWorkspaceSessionsResponse,
|
||||||
|
readonly kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.ListUserSessions
|
||||||
|
*/
|
||||||
|
readonly listUserSessions: {
|
||||||
|
readonly name: "ListUserSessions",
|
||||||
|
readonly I: typeof ListUserSessionsRequest,
|
||||||
|
readonly O: typeof ListUserSessionsResponse,
|
||||||
|
readonly kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.KickUserFromWorkspace
|
||||||
|
*/
|
||||||
|
readonly kickUserFromWorkspace: {
|
||||||
|
readonly name: "KickUserFromWorkspace",
|
||||||
|
readonly I: typeof KickUserFromWorkspaceRequest,
|
||||||
|
readonly O: typeof KickUserFromWorkspaceResponse,
|
||||||
|
readonly kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.KickUser
|
||||||
|
*/
|
||||||
|
readonly kickUser: {
|
||||||
|
readonly name: "KickUser",
|
||||||
|
readonly I: typeof KickUserRequest,
|
||||||
|
readonly O: typeof KickUserResponse,
|
||||||
|
readonly kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.GetUserStatus
|
||||||
|
*/
|
||||||
|
readonly getUserStatus: {
|
||||||
|
readonly name: "GetUserStatus",
|
||||||
|
readonly I: typeof GetUserStatusRequest,
|
||||||
|
readonly O: typeof GetUserStatusResponse,
|
||||||
|
readonly kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.GetUserInfo
|
||||||
|
*/
|
||||||
|
readonly getUserInfo: {
|
||||||
|
readonly name: "GetUserInfo",
|
||||||
|
readonly I: typeof GetUserInfoRequest,
|
||||||
|
readonly O: typeof GetUserInfoResponse,
|
||||||
|
readonly kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.GetWorkspaceOnlineUsers
|
||||||
|
*/
|
||||||
|
readonly getWorkspaceOnlineUsers: {
|
||||||
|
readonly name: "GetWorkspaceOnlineUsers",
|
||||||
|
readonly I: typeof GetWorkspaceOnlineUsersRequest,
|
||||||
|
readonly O: typeof GetWorkspaceOnlineUsersResponse,
|
||||||
|
readonly kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.IsUserOnline
|
||||||
|
*/
|
||||||
|
readonly isUserOnline: {
|
||||||
|
readonly name: "IsUserOnline",
|
||||||
|
readonly I: typeof IsUserOnlineRequest,
|
||||||
|
readonly O: typeof IsUserOnlineResponse,
|
||||||
|
readonly kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.GetMetrics
|
||||||
|
*/
|
||||||
|
readonly getMetrics: {
|
||||||
|
readonly name: "GetMetrics",
|
||||||
|
readonly I: typeof GetMetricsRequest,
|
||||||
|
readonly O: typeof GetMetricsResponse,
|
||||||
|
readonly kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.ExportMetricsCsv
|
||||||
|
*/
|
||||||
|
readonly exportMetricsCsv: {
|
||||||
|
readonly name: "ExportMetricsCsv",
|
||||||
|
readonly I: typeof ExportMetricsCsvRequest,
|
||||||
|
readonly O: typeof ExportMetricsCsvResponse,
|
||||||
|
readonly kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* AI
|
||||||
|
*
|
||||||
|
* @generated from rpc admin.SessionAdmin.SyncModels
|
||||||
|
*/
|
||||||
|
readonly syncModels: {
|
||||||
|
readonly name: "SyncModels",
|
||||||
|
readonly I: typeof SyncModelsRequest,
|
||||||
|
readonly O: typeof SyncModelsResponse,
|
||||||
|
readonly kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.CheckAlerts
|
||||||
|
*/
|
||||||
|
readonly checkAlerts: {
|
||||||
|
readonly name: "CheckAlerts",
|
||||||
|
readonly I: typeof CheckAlertsRequest,
|
||||||
|
readonly O: typeof CheckAlertsResponse,
|
||||||
|
readonly kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* AI Provider
|
||||||
|
*
|
||||||
|
* @generated from rpc admin.SessionAdmin.CreateProvider
|
||||||
|
*/
|
||||||
|
readonly createProvider: {
|
||||||
|
readonly name: "CreateProvider",
|
||||||
|
readonly I: typeof CreateProviderRequest,
|
||||||
|
readonly O: typeof ProviderResponse,
|
||||||
|
readonly kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.UpdateProvider
|
||||||
|
*/
|
||||||
|
readonly updateProvider: {
|
||||||
|
readonly name: "UpdateProvider",
|
||||||
|
readonly I: typeof UpdateProviderRequest,
|
||||||
|
readonly O: typeof ProviderResponse,
|
||||||
|
readonly kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.DeleteProvider
|
||||||
|
*/
|
||||||
|
readonly deleteProvider: {
|
||||||
|
readonly name: "DeleteProvider",
|
||||||
|
readonly I: typeof DeleteProviderRequest,
|
||||||
|
readonly O: typeof DeleteResponse,
|
||||||
|
readonly kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* AI Model
|
||||||
|
*
|
||||||
|
* @generated from rpc admin.SessionAdmin.CreateModel
|
||||||
|
*/
|
||||||
|
readonly createModel: {
|
||||||
|
readonly name: "CreateModel",
|
||||||
|
readonly I: typeof CreateModelRequest,
|
||||||
|
readonly O: typeof ModelResponse,
|
||||||
|
readonly kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.UpdateModel
|
||||||
|
*/
|
||||||
|
readonly updateModel: {
|
||||||
|
readonly name: "UpdateModel",
|
||||||
|
readonly I: typeof UpdateModelRequest,
|
||||||
|
readonly O: typeof ModelResponse,
|
||||||
|
readonly kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.DeleteModel
|
||||||
|
*/
|
||||||
|
readonly deleteModel: {
|
||||||
|
readonly name: "DeleteModel",
|
||||||
|
readonly I: typeof DeleteModelRequest,
|
||||||
|
readonly O: typeof DeleteResponse,
|
||||||
|
readonly kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* AI Version
|
||||||
|
*
|
||||||
|
* @generated from rpc admin.SessionAdmin.CreateVersion
|
||||||
|
*/
|
||||||
|
readonly createVersion: {
|
||||||
|
readonly name: "CreateVersion",
|
||||||
|
readonly I: typeof CreateVersionRequest,
|
||||||
|
readonly O: typeof VersionResponse,
|
||||||
|
readonly kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.UpdateVersion
|
||||||
|
*/
|
||||||
|
readonly updateVersion: {
|
||||||
|
readonly name: "UpdateVersion",
|
||||||
|
readonly I: typeof UpdateVersionRequest,
|
||||||
|
readonly O: typeof VersionResponse,
|
||||||
|
readonly kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.DeleteVersion
|
||||||
|
*/
|
||||||
|
readonly deleteVersion: {
|
||||||
|
readonly name: "DeleteVersion",
|
||||||
|
readonly I: typeof DeleteVersionRequest,
|
||||||
|
readonly O: typeof DeleteResponse,
|
||||||
|
readonly kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* AI Pricing
|
||||||
|
*
|
||||||
|
* @generated from rpc admin.SessionAdmin.UpdatePricing
|
||||||
|
*/
|
||||||
|
readonly updatePricing: {
|
||||||
|
readonly name: "UpdatePricing",
|
||||||
|
readonly I: typeof UpdatePricingRequest,
|
||||||
|
readonly O: typeof PricingResponse,
|
||||||
|
readonly kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
225
admin/src/lib/adminrpc/generated/proto/admin_connect.js
Normal file
225
admin/src/lib/adminrpc/generated/proto/admin_connect.js
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
// @generated by protoc-gen-connect-es v0.13.0
|
||||||
|
// @generated from file proto/admin.proto (package admin, syntax proto3)
|
||||||
|
/* eslint-disable */
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
import { CheckAlertsRequest, CheckAlertsResponse, CreateModelRequest, CreateProviderRequest, CreateVersionRequest, DeleteModelRequest, DeleteProviderRequest, DeleteResponse, DeleteVersionRequest, ExportMetricsCsvRequest, ExportMetricsCsvResponse, GetMetricsRequest, GetMetricsResponse, GetUserInfoRequest, GetUserInfoResponse, GetUserStatusRequest, GetUserStatusResponse, GetWorkspaceOnlineUsersRequest, GetWorkspaceOnlineUsersResponse, IsUserOnlineRequest, IsUserOnlineResponse, KickUserFromWorkspaceRequest, KickUserFromWorkspaceResponse, KickUserRequest, KickUserResponse, ListUserSessionsRequest, ListUserSessionsResponse, ListWorkspaceSessionsRequest, ListWorkspaceSessionsResponse, ModelResponse, PricingResponse, ProviderResponse, SyncModelsRequest, SyncModelsResponse, UpdateModelRequest, UpdatePricingRequest, UpdateProviderRequest, UpdateVersionRequest, VersionResponse } from "./admin_pb.js";
|
||||||
|
import { MethodKind } from "@bufbuild/protobuf";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from service admin.SessionAdmin
|
||||||
|
*/
|
||||||
|
export const SessionAdmin = {
|
||||||
|
typeName: "admin.SessionAdmin",
|
||||||
|
methods: {
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.ListWorkspaceSessions
|
||||||
|
*/
|
||||||
|
listWorkspaceSessions: {
|
||||||
|
name: "ListWorkspaceSessions",
|
||||||
|
I: ListWorkspaceSessionsRequest,
|
||||||
|
O: ListWorkspaceSessionsResponse,
|
||||||
|
kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.ListUserSessions
|
||||||
|
*/
|
||||||
|
listUserSessions: {
|
||||||
|
name: "ListUserSessions",
|
||||||
|
I: ListUserSessionsRequest,
|
||||||
|
O: ListUserSessionsResponse,
|
||||||
|
kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.KickUserFromWorkspace
|
||||||
|
*/
|
||||||
|
kickUserFromWorkspace: {
|
||||||
|
name: "KickUserFromWorkspace",
|
||||||
|
I: KickUserFromWorkspaceRequest,
|
||||||
|
O: KickUserFromWorkspaceResponse,
|
||||||
|
kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.KickUser
|
||||||
|
*/
|
||||||
|
kickUser: {
|
||||||
|
name: "KickUser",
|
||||||
|
I: KickUserRequest,
|
||||||
|
O: KickUserResponse,
|
||||||
|
kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.GetUserStatus
|
||||||
|
*/
|
||||||
|
getUserStatus: {
|
||||||
|
name: "GetUserStatus",
|
||||||
|
I: GetUserStatusRequest,
|
||||||
|
O: GetUserStatusResponse,
|
||||||
|
kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.GetUserInfo
|
||||||
|
*/
|
||||||
|
getUserInfo: {
|
||||||
|
name: "GetUserInfo",
|
||||||
|
I: GetUserInfoRequest,
|
||||||
|
O: GetUserInfoResponse,
|
||||||
|
kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.GetWorkspaceOnlineUsers
|
||||||
|
*/
|
||||||
|
getWorkspaceOnlineUsers: {
|
||||||
|
name: "GetWorkspaceOnlineUsers",
|
||||||
|
I: GetWorkspaceOnlineUsersRequest,
|
||||||
|
O: GetWorkspaceOnlineUsersResponse,
|
||||||
|
kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.IsUserOnline
|
||||||
|
*/
|
||||||
|
isUserOnline: {
|
||||||
|
name: "IsUserOnline",
|
||||||
|
I: IsUserOnlineRequest,
|
||||||
|
O: IsUserOnlineResponse,
|
||||||
|
kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.GetMetrics
|
||||||
|
*/
|
||||||
|
getMetrics: {
|
||||||
|
name: "GetMetrics",
|
||||||
|
I: GetMetricsRequest,
|
||||||
|
O: GetMetricsResponse,
|
||||||
|
kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.ExportMetricsCsv
|
||||||
|
*/
|
||||||
|
exportMetricsCsv: {
|
||||||
|
name: "ExportMetricsCsv",
|
||||||
|
I: ExportMetricsCsvRequest,
|
||||||
|
O: ExportMetricsCsvResponse,
|
||||||
|
kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* AI
|
||||||
|
*
|
||||||
|
* @generated from rpc admin.SessionAdmin.SyncModels
|
||||||
|
*/
|
||||||
|
syncModels: {
|
||||||
|
name: "SyncModels",
|
||||||
|
I: SyncModelsRequest,
|
||||||
|
O: SyncModelsResponse,
|
||||||
|
kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.CheckAlerts
|
||||||
|
*/
|
||||||
|
checkAlerts: {
|
||||||
|
name: "CheckAlerts",
|
||||||
|
I: CheckAlertsRequest,
|
||||||
|
O: CheckAlertsResponse,
|
||||||
|
kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* AI Provider
|
||||||
|
*
|
||||||
|
* @generated from rpc admin.SessionAdmin.CreateProvider
|
||||||
|
*/
|
||||||
|
createProvider: {
|
||||||
|
name: "CreateProvider",
|
||||||
|
I: CreateProviderRequest,
|
||||||
|
O: ProviderResponse,
|
||||||
|
kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.UpdateProvider
|
||||||
|
*/
|
||||||
|
updateProvider: {
|
||||||
|
name: "UpdateProvider",
|
||||||
|
I: UpdateProviderRequest,
|
||||||
|
O: ProviderResponse,
|
||||||
|
kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.DeleteProvider
|
||||||
|
*/
|
||||||
|
deleteProvider: {
|
||||||
|
name: "DeleteProvider",
|
||||||
|
I: DeleteProviderRequest,
|
||||||
|
O: DeleteResponse,
|
||||||
|
kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* AI Model
|
||||||
|
*
|
||||||
|
* @generated from rpc admin.SessionAdmin.CreateModel
|
||||||
|
*/
|
||||||
|
createModel: {
|
||||||
|
name: "CreateModel",
|
||||||
|
I: CreateModelRequest,
|
||||||
|
O: ModelResponse,
|
||||||
|
kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.UpdateModel
|
||||||
|
*/
|
||||||
|
updateModel: {
|
||||||
|
name: "UpdateModel",
|
||||||
|
I: UpdateModelRequest,
|
||||||
|
O: ModelResponse,
|
||||||
|
kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.DeleteModel
|
||||||
|
*/
|
||||||
|
deleteModel: {
|
||||||
|
name: "DeleteModel",
|
||||||
|
I: DeleteModelRequest,
|
||||||
|
O: DeleteResponse,
|
||||||
|
kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* AI Version
|
||||||
|
*
|
||||||
|
* @generated from rpc admin.SessionAdmin.CreateVersion
|
||||||
|
*/
|
||||||
|
createVersion: {
|
||||||
|
name: "CreateVersion",
|
||||||
|
I: CreateVersionRequest,
|
||||||
|
O: VersionResponse,
|
||||||
|
kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.UpdateVersion
|
||||||
|
*/
|
||||||
|
updateVersion: {
|
||||||
|
name: "UpdateVersion",
|
||||||
|
I: UpdateVersionRequest,
|
||||||
|
O: VersionResponse,
|
||||||
|
kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc admin.SessionAdmin.DeleteVersion
|
||||||
|
*/
|
||||||
|
deleteVersion: {
|
||||||
|
name: "DeleteVersion",
|
||||||
|
I: DeleteVersionRequest,
|
||||||
|
O: DeleteResponse,
|
||||||
|
kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* AI Pricing
|
||||||
|
*
|
||||||
|
* @generated from rpc admin.SessionAdmin.UpdatePricing
|
||||||
|
*/
|
||||||
|
updatePricing: {
|
||||||
|
name: "UpdatePricing",
|
||||||
|
I: UpdatePricingRequest,
|
||||||
|
O: PricingResponse,
|
||||||
|
kind: MethodKind.Unary,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
1017
admin/src/lib/adminrpc/generated/proto/admin_pb.d.ts
vendored
Normal file
1017
admin/src/lib/adminrpc/generated/proto/admin_pb.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
325
admin/src/lib/adminrpc/generated/proto/admin_pb.js
Normal file
325
admin/src/lib/adminrpc/generated/proto/admin_pb.js
Normal file
File diff suppressed because one or more lines are too long
@ -167,7 +167,8 @@ export async function touchSession(sessionId: string): Promise<void> {
|
|||||||
const state = await loadSession(sessionId);
|
const state = await loadSession(sessionId);
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
state["session:last_active"] = new Date().toISOString();
|
state["session:last_active"] = new Date().toISOString();
|
||||||
await refreshSessionTtl(sessionId, ADMIN_SESSION_TTL);
|
const { saveSession } = await import("@/lib/redis");
|
||||||
|
await saveSession(sessionId, state, ADMIN_SESSION_TTL);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 登出 ============
|
// ============ 登出 ============
|
||||||
|
|||||||
@ -30,12 +30,16 @@ async function runReport() {
|
|||||||
|
|
||||||
// Call generate endpoint internally (server-side fetch, no auth needed for cron)
|
// Call generate endpoint internally (server-side fetch, no auth needed for cron)
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || `http://localhost:${process.env.PORT || 3000}`;
|
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || `http://localhost:${process.env.PORT || 3000}`;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-cron-internal": "true",
|
||||||
|
};
|
||||||
|
if (process.env.DAILY_REPORT_CRON_SECRET) {
|
||||||
|
headers["x-cron-secret"] = process.env.DAILY_REPORT_CRON_SECRET;
|
||||||
|
}
|
||||||
const res = await fetch(`${baseUrl}/api/admin/daily-report/generate`, {
|
const res = await fetch(`${baseUrl}/api/admin/daily-report/generate`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers,
|
||||||
"Content-Type": "application/json",
|
|
||||||
"x-cron-internal": "true", // internal marker, not x-cron-secret
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
console.log("[daily-report-cron] Result:", res.status, data);
|
console.log("[daily-report-cron] Result:", res.status, data);
|
||||||
|
|||||||
@ -5,29 +5,29 @@
|
|||||||
|
|
||||||
// 数据库
|
// 数据库
|
||||||
export const DATABASE_URL =
|
export const DATABASE_URL =
|
||||||
process.env.DATABASE_URL || "postgresql://localhost:5432/code";
|
process.env.DATABASE_URL || "postgresql://localhost:5432/code";
|
||||||
|
|
||||||
// Redis
|
// Redis
|
||||||
export const REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379";
|
export const REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379";
|
||||||
// Redis Cluster 节点列表(逗号分隔,用于 ioredis cluster 模式)
|
// Redis Cluster 节点列表(逗号分隔,用于 ioredis cluster 模式)
|
||||||
export const REDIS_CLUSTER_URLS = (process.env.REDIS_CLUSTER_URLS || "")
|
export const REDIS_CLUSTER_URLS = (process.env.REDIS_CLUSTER_URLS || "")
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((u) => u.trim())
|
.map((u) => u.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
// Session
|
// Session
|
||||||
export const ADMIN_SESSION_COOKIE_NAME =
|
export const ADMIN_SESSION_COOKIE_NAME =
|
||||||
process.env.ADMIN_SESSION_COOKIE_NAME || "admin_session";
|
process.env.ADMIN_SESSION_COOKIE_NAME || "admin_session";
|
||||||
export const ADMIN_SESSION_TTL = parseInt(
|
export const ADMIN_SESSION_TTL = parseInt(
|
||||||
process.env.ADMIN_SESSION_TTL || "604800",
|
process.env.ADMIN_SESSION_TTL || "604800",
|
||||||
10
|
10
|
||||||
); // 7 days
|
); // 7 days
|
||||||
|
|
||||||
// 超级管理员(环境变量配置)
|
// 超级管理员(环境变量配置)
|
||||||
export const ADMIN_SUPER_USERNAME = process.env.ADMIN_SUPER_USERNAME || "";
|
export const ADMIN_SUPER_USERNAME = process.env.ADMIN_SUPER_USERNAME || "";
|
||||||
export const ADMIN_SUPER_PASSWORD = process.env.ADMIN_SUPER_PASSWORD || "";
|
export const ADMIN_SUPER_PASSWORD = process.env.ADMIN_SUPER_PASSWORD || "";
|
||||||
export const ADMIN_SUPER_PASSWORD_HASH =
|
export const ADMIN_SUPER_PASSWORD_HASH =
|
||||||
process.env.ADMIN_SUPER_PASSWORD_HASH || "";
|
process.env.ADMIN_SUPER_PASSWORD_HASH || "";
|
||||||
|
|
||||||
// OIDC
|
// OIDC
|
||||||
export const OIDC_ENABLED = process.env.OIDC_ENABLED === "true";
|
export const OIDC_ENABLED = process.env.OIDC_ENABLED === "true";
|
||||||
@ -35,21 +35,21 @@ export const OIDC_ISSUER = process.env.OIDC_ISSUER || "";
|
|||||||
export const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID || "";
|
export const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID || "";
|
||||||
export const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET || "";
|
export const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET || "";
|
||||||
export const OIDC_REDIRECT_URI =
|
export const OIDC_REDIRECT_URI =
|
||||||
process.env.OIDC_REDIRECT_URI ||
|
process.env.OIDC_REDIRECT_URI ||
|
||||||
"http://localhost:3000/api/auth/oidc/callback";
|
"http://localhost:3000/api/auth/oidc/callback";
|
||||||
|
|
||||||
// Cookie 安全
|
// Cookie 安全
|
||||||
export const COOKIE_SECURE = process.env.COOKIE_SECURE === "true";
|
export const COOKIE_SECURE = process.env.COOKIE_SECURE === "true";
|
||||||
export const COOKIE_SAME_SITE =
|
export const COOKIE_SAME_SITE =
|
||||||
(process.env.COOKIE_SAME_SITE as "strict" | "lax" | "none") || "lax";
|
(process.env.COOKIE_SAME_SITE as "strict" | "lax" | "none") || "lax";
|
||||||
|
|
||||||
// Rust 主应用集成
|
// Rust 主应用集成
|
||||||
export const RUST_BACKEND_URL =
|
export const RUST_BACKEND_URL =
|
||||||
process.env.RUST_BACKEND_URL || "http://localhost:3000";
|
process.env.RUST_BACKEND_URL || "http://localhost:3000";
|
||||||
export const ADMIN_API_SHARED_KEY =
|
export const ADMIN_API_SHARED_KEY =
|
||||||
process.env.ADMIN_API_SHARED_KEY || "";
|
process.env.ADMIN_API_SHARED_KEY || "";
|
||||||
|
|
||||||
// adminrpc HTTP 服务地址(k8s 内部默认地址)
|
// adminrpc HTTP 服务地址(k8s 内部默认地址)
|
||||||
// 在 Kubernetes 环境中默认使用 Service DNS,在本地开发时覆盖为 localhost:9091
|
// 在 Kubernetes 环境中默认使用 Service DNS,在本地开发时覆盖为 localhost:9091
|
||||||
export const ADMIN_RPC_URL =
|
export const ADMIN_RPC_URL =
|
||||||
process.env.ADMIN_RPC_URL || "http://adminrpc.admin.svc.cluster.local:9091";
|
process.env.ADMIN_RPC_URL || "http://adminrpc.gitdataai.svc.cluster.local:9091";
|
||||||
|
|||||||
@ -201,15 +201,12 @@ export async function listUsers(
|
|||||||
const pageSize = options.pageSize ?? 20;
|
const pageSize = options.pageSize ?? 20;
|
||||||
const offset = (page - 1) * pageSize;
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
const params: unknown[] = [pageSize, offset];
|
const params: unknown[] = [
|
||||||
let whereClause = "";
|
...(options.search ? [`%${options.search}%`] : []),
|
||||||
let paramIdx = 3;
|
pageSize,
|
||||||
|
offset,
|
||||||
if (options.search) {
|
];
|
||||||
whereClause = `WHERE username ILIKE $1`;
|
const whereClause = options.search ? `WHERE username ILIKE $1` : "";
|
||||||
params.unshift(`%${options.search}%`);
|
|
||||||
paramIdx = 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
const countParams = options.search ? [`%${options.search}%`] : [];
|
const countParams = options.search ? [`%${options.search}%`] : [];
|
||||||
const countResult = await query<{ count: string }>(
|
const countResult = await query<{ count: string }>(
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { REDIS_URL, REDIS_CLUSTER_URLS } from "./env";
|
|||||||
// Admin 专用的 Redis 前缀
|
// Admin 专用的 Redis 前缀
|
||||||
const ADMIN_PREFIX = "admin:session:";
|
const ADMIN_PREFIX = "admin:session:";
|
||||||
// 平台用户 Session 前缀(与 Rust 主应用一致)
|
// 平台用户 Session 前缀(与 Rust 主应用一致)
|
||||||
const PLATFORM_SESSION_PREFIX = "session:user_uid:";
|
const PLATFORM_SESSION_PREFIX = "user:";
|
||||||
|
|
||||||
let redis: Redis | null = null;
|
let redis: Redis | null = null;
|
||||||
|
|
||||||
|
|||||||
@ -124,6 +124,7 @@ export async function middleware(req: NextRequest) {
|
|||||||
permissions = tokenResult.permissions || [];
|
permissions = tokenResult.permissions || [];
|
||||||
headers.set("x-admin-auth-type", "token");
|
headers.set("x-admin-auth-type", "token");
|
||||||
headers.set("x-admin-token-id", String(tokenResult.tokenId));
|
headers.set("x-admin-token-id", String(tokenResult.tokenId));
|
||||||
|
headers.set("x-admin-permissions", permissions.join(","));
|
||||||
} else {
|
} else {
|
||||||
// 回退到 Session 认证
|
// 回退到 Session 认证
|
||||||
const cookieHeader = req.headers.get("cookie");
|
const cookieHeader = req.headers.get("cookie");
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
use std::net::SocketAddr;
|
|
||||||
use actix_web::{web, App as ActixApp, HttpResponse, HttpServer};
|
use actix_web::{web, App as ActixApp, HttpResponse, HttpServer};
|
||||||
use anyhow::Context as _;
|
use anyhow::Context as _;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use config::AppConfig;
|
use config::AppConfig;
|
||||||
use deadpool_redis::{cluster, Runtime};
|
use deadpool_redis::{cluster, Runtime};
|
||||||
use session_manager::{SessionManager, SessionStorage};
|
|
||||||
use rpc::admin::server::{serve, DEFAULT_GRPC_PORT};
|
use rpc::admin::server::{serve, DEFAULT_GRPC_PORT};
|
||||||
|
use session_manager::{SessionManager, SessionStorage};
|
||||||
|
use std::net::SocketAddr;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
mod args;
|
mod args;
|
||||||
@ -24,9 +24,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.unwrap_or_else(|| format!("0.0.0.0:{}", DEFAULT_GRPC_PORT).parse())
|
.unwrap_or_else(|| format!("0.0.0.0:{}", DEFAULT_GRPC_PORT).parse())
|
||||||
.context("invalid grpc bind address")?;
|
.context("invalid grpc bind address")?;
|
||||||
|
|
||||||
// Admin HTTP port is gRPC port + 1 (e.g., 9091)
|
|
||||||
let admin_port: u16 = args.http_port.unwrap_or(grpc_addr.port() + 1);
|
let admin_port: u16 = args.http_port.unwrap_or(grpc_addr.port() + 1);
|
||||||
let admin_addr: SocketAddr = format!("0.0.0.0:{}", admin_port).parse().unwrap();
|
let admin_addr: SocketAddr = format!("0.0.0.0:{}", admin_port).parse()?;
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
app_name = %cfg.app_name().unwrap_or_default(),
|
app_name = %cfg.app_name().unwrap_or_default(),
|
||||||
@ -35,20 +34,25 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
"Starting admin RPC server"
|
"Starting admin RPC server"
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── OTLP tracing ─────────────────────────────────────────────────────────
|
|
||||||
let _otel_guard = if cfg.otel_enabled().unwrap_or(false) {
|
let _otel_guard = if cfg.otel_enabled().unwrap_or(false) {
|
||||||
let endpoint = cfg.otel_endpoint().unwrap_or_else(|_| "http://localhost:4317".to_string());
|
let endpoint = cfg
|
||||||
let service_name = cfg.otel_service_name().unwrap_or_else(|_| "adminrpc".to_string());
|
.otel_endpoint()
|
||||||
let service_version = cfg.otel_service_version().unwrap_or_else(|_| "0.1.0".to_string());
|
.unwrap_or_else(|_| "http://localhost:4317".to_string());
|
||||||
|
let service_name = cfg
|
||||||
|
.otel_service_name()
|
||||||
|
.unwrap_or_else(|_| "adminrpc".to_string());
|
||||||
|
let service_version = cfg
|
||||||
|
.otel_service_version()
|
||||||
|
.unwrap_or_else(|_| "0.1.0".to_string());
|
||||||
tracing::info!(endpoint = %endpoint, service = %service_name, "OTLP tracing enabled");
|
tracing::info!(endpoint = %endpoint, service = %service_name, "OTLP tracing enabled");
|
||||||
let guard = observability::init_otlp(&endpoint, &service_name, &service_version, &log_level)
|
let guard =
|
||||||
.map_err(|e| anyhow::anyhow!("OTLP init failed: {}", e))?;
|
observability::init_otlp(&endpoint, &service_name, &service_version, &log_level)
|
||||||
|
.map_err(|e| anyhow::anyhow!("OTLP init failed: {}", e))?;
|
||||||
guard
|
guard
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
// Redis connection pool
|
|
||||||
let redis_url = cfg.redis_url()?;
|
let redis_url = cfg.redis_url()?;
|
||||||
tracing::info!(redis_url = %redis_url, "Connecting to Redis");
|
tracing::info!(redis_url = %redis_url, "Connecting to Redis");
|
||||||
let manager = cluster::Manager::new(vec![redis_url.clone()], false)
|
let manager = cluster::Manager::new(vec![redis_url.clone()], false)
|
||||||
@ -65,7 +69,6 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let storage = SessionStorage::new(pool.clone());
|
let storage = SessionStorage::new(pool.clone());
|
||||||
let session_manager = SessionManager::new(storage);
|
let session_manager = SessionManager::new(storage);
|
||||||
|
|
||||||
// Spawn gRPC server in background
|
|
||||||
let sm_for_grpc = session_manager.clone();
|
let sm_for_grpc = session_manager.clone();
|
||||||
let grpc_handle = tokio::spawn(async move {
|
let grpc_handle = tokio::spawn(async move {
|
||||||
if let Err(e) = serve(grpc_addr, sm_for_grpc).await {
|
if let Err(e) = serve(grpc_addr, sm_for_grpc).await {
|
||||||
@ -73,7 +76,6 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start HTTP REST server
|
|
||||||
let http_handle = tokio::spawn(async move {
|
let http_handle = tokio::spawn(async move {
|
||||||
let pool_for_http = pool.clone();
|
let pool_for_http = pool.clone();
|
||||||
let sm_for_http = session_manager.clone();
|
let sm_for_http = session_manager.clone();
|
||||||
@ -86,15 +88,35 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.route("/admin/metrics/export", web::get().to(metrics_export))
|
.route("/admin/metrics/export", web::get().to(metrics_export))
|
||||||
.service(
|
.service(
|
||||||
web::scope("/api/admin")
|
web::scope("/api/admin")
|
||||||
// Sessions
|
.route(
|
||||||
.route("/sessions/workspace/{workspace_id}", web::get().to(list_workspace_sessions))
|
"/sessions/workspace/{workspace_id}",
|
||||||
.route("/sessions/user/{user_id}", web::get().to(list_user_sessions))
|
web::get().to(list_workspace_sessions),
|
||||||
.route("/sessions/user/{user_id}/status", web::get().to(get_user_status))
|
)
|
||||||
.route("/sessions/user/{user_id}/info", web::get().to(get_user_info))
|
.route(
|
||||||
.route("/sessions/workspace/{workspace_id}/online-users", web::get().to(get_workspace_online_users))
|
"/sessions/user/{user_id}",
|
||||||
.route("/sessions/user/{user_id}/online", web::get().to(is_user_online))
|
web::get().to(list_user_sessions),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/sessions/user/{user_id}/status",
|
||||||
|
web::get().to(get_user_status),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/sessions/user/{user_id}/info",
|
||||||
|
web::get().to(get_user_info),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/sessions/workspace/{workspace_id}/online-users",
|
||||||
|
web::get().to(get_workspace_online_users),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/sessions/user/{user_id}/online",
|
||||||
|
web::get().to(is_user_online),
|
||||||
|
)
|
||||||
.route("/sessions/kick", web::post().to(kick_user))
|
.route("/sessions/kick", web::post().to(kick_user))
|
||||||
.route("/sessions/kick-workspace", web::post().to(kick_user_from_workspace))
|
.route(
|
||||||
|
"/sessions/kick-workspace",
|
||||||
|
web::post().to(kick_user_from_workspace),
|
||||||
|
)
|
||||||
// Metrics
|
// Metrics
|
||||||
.route("/metrics", web::get().to(get_metrics))
|
.route("/metrics", web::get().to(get_metrics))
|
||||||
.route("/metrics/export", web::get().to(metrics_export)),
|
.route("/metrics/export", web::get().to(metrics_export)),
|
||||||
@ -131,14 +153,18 @@ async fn metrics_export(pool: web::Data<cluster::Pool>) -> HttpResponse {
|
|||||||
Ok(csv) => HttpResponse::Ok()
|
Ok(csv) => HttpResponse::Ok()
|
||||||
.content_type("text/csv; charset=utf-8")
|
.content_type("text/csv; charset=utf-8")
|
||||||
.body(csv),
|
.body(csv),
|
||||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() })),
|
Err(e) => {
|
||||||
|
HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_metrics(pool: web::Data<cluster::Pool>) -> HttpResponse {
|
async fn get_metrics(pool: web::Data<cluster::Pool>) -> HttpResponse {
|
||||||
match observability::query_all_instance_metrics(pool.get_ref(), "", 100).await {
|
match observability::query_all_instance_metrics(pool.get_ref(), "", 100).await {
|
||||||
Ok(instances) => HttpResponse::Ok().json(instances),
|
Ok(instances) => HttpResponse::Ok().json(instances),
|
||||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() })),
|
Err(e) => {
|
||||||
|
HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -152,11 +178,16 @@ async fn list_workspace_sessions(
|
|||||||
) -> HttpResponse {
|
) -> HttpResponse {
|
||||||
let workspace_id = match parse_uuid(&path) {
|
let workspace_id = match parse_uuid(&path) {
|
||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => return HttpResponse::BadRequest().json(serde_json::json!({ "error": "invalid workspace_id" })),
|
None => {
|
||||||
|
return HttpResponse::BadRequest()
|
||||||
|
.json(serde_json::json!({ "error": "invalid workspace_id" }));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
match sm.get_workspace_sessions(&workspace_id).await {
|
match sm.get_workspace_sessions(&workspace_id).await {
|
||||||
Ok(sessions) => HttpResponse::Ok().json(sessions),
|
Ok(sessions) => HttpResponse::Ok().json(sessions),
|
||||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() })),
|
Err(e) => {
|
||||||
|
HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -166,39 +197,50 @@ async fn list_user_sessions(
|
|||||||
) -> HttpResponse {
|
) -> HttpResponse {
|
||||||
let user_id = match parse_uuid(&path) {
|
let user_id = match parse_uuid(&path) {
|
||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => return HttpResponse::BadRequest().json(serde_json::json!({ "error": "invalid user_id" })),
|
None => {
|
||||||
|
return HttpResponse::BadRequest()
|
||||||
|
.json(serde_json::json!({ "error": "invalid user_id" }));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
match sm.get_user_sessions(&user_id).await {
|
match sm.get_user_sessions(&user_id).await {
|
||||||
Ok(sessions) => HttpResponse::Ok().json(sessions),
|
Ok(sessions) => HttpResponse::Ok().json(sessions),
|
||||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() })),
|
Err(e) => {
|
||||||
|
HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_user_status(
|
async fn get_user_status(sm: web::Data<SessionManager>, path: web::Path<String>) -> HttpResponse {
|
||||||
sm: web::Data<SessionManager>,
|
|
||||||
path: web::Path<String>,
|
|
||||||
) -> HttpResponse {
|
|
||||||
let user_id = match parse_uuid(&path) {
|
let user_id = match parse_uuid(&path) {
|
||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => return HttpResponse::BadRequest().json(serde_json::json!({ "error": "invalid user_id" })),
|
None => {
|
||||||
|
return HttpResponse::BadRequest()
|
||||||
|
.json(serde_json::json!({ "error": "invalid user_id" }));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
match sm.get_user_status(&user_id).await {
|
match sm.get_user_status(&user_id).await {
|
||||||
Ok(status) => HttpResponse::Ok().json(serde_json::json!({ "status": format!("{:?}", status) })),
|
Ok(status) => {
|
||||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() })),
|
HttpResponse::Ok().json(serde_json::json!({ "status": format!("{:?}", status) }))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_user_info(
|
async fn get_user_info(sm: web::Data<SessionManager>, path: web::Path<String>) -> HttpResponse {
|
||||||
sm: web::Data<SessionManager>,
|
|
||||||
path: web::Path<String>,
|
|
||||||
) -> HttpResponse {
|
|
||||||
let user_id = match parse_uuid(&path) {
|
let user_id = match parse_uuid(&path) {
|
||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => return HttpResponse::BadRequest().json(serde_json::json!({ "error": "invalid user_id" })),
|
None => {
|
||||||
|
return HttpResponse::BadRequest()
|
||||||
|
.json(serde_json::json!({ "error": "invalid user_id" }));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
match sm.get_user_info(&user_id).await {
|
match sm.get_user_info(&user_id).await {
|
||||||
Ok(info) => HttpResponse::Ok().json(info),
|
Ok(info) => HttpResponse::Ok().json(info),
|
||||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() })),
|
Err(e) => {
|
||||||
|
HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -208,25 +250,32 @@ async fn get_workspace_online_users(
|
|||||||
) -> HttpResponse {
|
) -> HttpResponse {
|
||||||
let workspace_id = match parse_uuid(&path) {
|
let workspace_id = match parse_uuid(&path) {
|
||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => return HttpResponse::BadRequest().json(serde_json::json!({ "error": "invalid workspace_id" })),
|
None => {
|
||||||
|
return HttpResponse::BadRequest()
|
||||||
|
.json(serde_json::json!({ "error": "invalid workspace_id" }));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
match sm.get_workspace_online_users(&workspace_id).await {
|
match sm.get_workspace_online_users(&workspace_id).await {
|
||||||
Ok(user_ids) => HttpResponse::Ok().json(serde_json::json!({ "user_ids": user_ids })),
|
Ok(user_ids) => HttpResponse::Ok().json(serde_json::json!({ "user_ids": user_ids })),
|
||||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() })),
|
Err(e) => {
|
||||||
|
HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn is_user_online(
|
async fn is_user_online(sm: web::Data<SessionManager>, path: web::Path<String>) -> HttpResponse {
|
||||||
sm: web::Data<SessionManager>,
|
|
||||||
path: web::Path<String>,
|
|
||||||
) -> HttpResponse {
|
|
||||||
let user_id = match parse_uuid(&path) {
|
let user_id = match parse_uuid(&path) {
|
||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => return HttpResponse::BadRequest().json(serde_json::json!({ "error": "invalid user_id" })),
|
None => {
|
||||||
|
return HttpResponse::BadRequest()
|
||||||
|
.json(serde_json::json!({ "error": "invalid user_id" }));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
match sm.is_user_online(&user_id).await {
|
match sm.is_user_online(&user_id).await {
|
||||||
Ok(online) => HttpResponse::Ok().json(serde_json::json!({ "online": online })),
|
Ok(online) => HttpResponse::Ok().json(serde_json::json!({ "online": online })),
|
||||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() })),
|
Err(e) => {
|
||||||
|
HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -241,11 +290,16 @@ async fn kick_user(
|
|||||||
) -> HttpResponse {
|
) -> HttpResponse {
|
||||||
let user_id = match parse_uuid(&body.user_id) {
|
let user_id = match parse_uuid(&body.user_id) {
|
||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => return HttpResponse::BadRequest().json(serde_json::json!({ "error": "invalid user_id" })),
|
None => {
|
||||||
|
return HttpResponse::BadRequest()
|
||||||
|
.json(serde_json::json!({ "error": "invalid user_id" }));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
match sm.kick_user(&user_id).await {
|
match sm.kick_user(&user_id).await {
|
||||||
Ok(count) => HttpResponse::Ok().json(serde_json::json!({ "kicked_count": count })),
|
Ok(count) => HttpResponse::Ok().json(serde_json::json!({ "kicked_count": count })),
|
||||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() })),
|
Err(e) => {
|
||||||
|
HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -261,14 +315,22 @@ async fn kick_user_from_workspace(
|
|||||||
) -> HttpResponse {
|
) -> HttpResponse {
|
||||||
let user_id = match parse_uuid(&body.user_id) {
|
let user_id = match parse_uuid(&body.user_id) {
|
||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => return HttpResponse::BadRequest().json(serde_json::json!({ "error": "invalid user_id" })),
|
None => {
|
||||||
|
return HttpResponse::BadRequest()
|
||||||
|
.json(serde_json::json!({ "error": "invalid user_id" }));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let workspace_id = match parse_uuid(&body.workspace_id) {
|
let workspace_id = match parse_uuid(&body.workspace_id) {
|
||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => return HttpResponse::BadRequest().json(serde_json::json!({ "error": "invalid workspace_id" })),
|
None => {
|
||||||
|
return HttpResponse::BadRequest()
|
||||||
|
.json(serde_json::json!({ "error": "invalid workspace_id" }));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
match sm.kick_user_from_workspace(&user_id, &workspace_id).await {
|
match sm.kick_user_from_workspace(&user_id, &workspace_id).await {
|
||||||
Ok(count) => HttpResponse::Ok().json(serde_json::json!({ "kicked_count": count })),
|
Ok(count) => HttpResponse::Ok().json(serde_json::json!({ "kicked_count": count })),
|
||||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() })),
|
Err(e) => {
|
||||||
|
HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
|
use std::time::Duration;
|
||||||
use async_openai::config::OpenAIConfig;
|
use async_openai::config::OpenAIConfig;
|
||||||
use async_openai::Client;
|
use async_openai::Client;
|
||||||
use async_openai::types::chat::{
|
use async_openai::types::chat::{
|
||||||
@ -707,6 +708,28 @@ impl ChatService {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns true if the error message indicates a transient failure that can be retried.
|
||||||
|
fn is_retryable_tool_error(msg: &str) -> bool {
|
||||||
|
let msg_lower = msg.to_lowercase();
|
||||||
|
// Transient errors: network, timeouts, rate limits, permission issues that may be temporary
|
||||||
|
msg_lower.contains("connection")
|
||||||
|
|| msg_lower.contains("timeout")
|
||||||
|
|| msg_lower.contains("timed out")
|
||||||
|
|| msg_lower.contains("rate limit")
|
||||||
|
|| msg_lower.contains("too many")
|
||||||
|
|| msg_lower.contains("unavailable")
|
||||||
|
|| msg_lower.contains("service unavailable")
|
||||||
|
|| msg_lower.contains("temporarily")
|
||||||
|
|| msg_lower.contains("refused")
|
||||||
|
|| msg_lower.contains("reset")
|
||||||
|
|| msg_lower.contains("broken pipe")
|
||||||
|
|| msg_lower.contains("deadline exceeded")
|
||||||
|
|| msg_lower.contains("try again")
|
||||||
|
|| msg_lower.contains("not found") // DB/Redis transient not-found
|
||||||
|
|| msg_lower.contains("permission denied")
|
||||||
|
|| msg_lower.contains("access denied")
|
||||||
|
}
|
||||||
|
|
||||||
/// Process a request using the ReAct (Reasoning + Acting) agent.
|
/// Process a request using the ReAct (Reasoning + Acting) agent.
|
||||||
///
|
///
|
||||||
/// Unlike the simple loop in `process`, the ReAct agent performs multi-step
|
/// Unlike the simple loop in `process`, the ReAct agent performs multi-step
|
||||||
@ -756,27 +779,70 @@ impl ChatService {
|
|||||||
let registry = registry.clone();
|
let registry = registry.clone();
|
||||||
|
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
let mut ctx = ToolContext::new(db, cache, config, room_id, sender_uid);
|
let max_retries = 3;
|
||||||
if let Some(pid) = project_id {
|
let mut last_err = String::new();
|
||||||
ctx = ctx.with_project(pid);
|
|
||||||
}
|
|
||||||
ctx.registry_mut().merge(registry.clone());
|
|
||||||
|
|
||||||
let tool_executor = ToolExecutor::new();
|
for attempt in 0..=max_retries {
|
||||||
let call = ToolCall {
|
let mut ctx = ToolContext::new(db.clone(), cache.clone(), config.clone(), room_id, sender_uid);
|
||||||
id: uuid::Uuid::new_v4().to_string(),
|
if let Some(pid) = project_id {
|
||||||
name,
|
ctx = ctx.with_project(pid);
|
||||||
arguments: serde_json::to_string(&args).unwrap_or_else(|_| "{}".into()),
|
}
|
||||||
};
|
ctx.registry_mut().merge(registry.clone());
|
||||||
let results: Vec<_> = tool_executor
|
|
||||||
.execute_batch(vec![call], &mut ctx)
|
let tool_executor = ToolExecutor::new();
|
||||||
.await
|
let call = ToolCall {
|
||||||
.map_err(|e| e.to_string())?;
|
id: Uuid::new_v4().to_string(),
|
||||||
let result = results.into_iter().next().ok_or_else(|| "no result".to_string())?;
|
name: name.clone(),
|
||||||
match result.result {
|
arguments: serde_json::to_string(&args).unwrap_or_else(|_| "{}".into()),
|
||||||
ToolResult::Ok(v) => Ok(v),
|
};
|
||||||
ToolResult::Error(msg) => Err(msg),
|
|
||||||
|
match tool_executor.execute_batch(vec![call], &mut ctx).await {
|
||||||
|
Ok(results) => {
|
||||||
|
let result = results.into_iter().next()
|
||||||
|
.ok_or_else(|| "no tool result returned".to_string())?;
|
||||||
|
match result.result {
|
||||||
|
ToolResult::Ok(v) => return Ok(v),
|
||||||
|
ToolResult::Error(msg) => {
|
||||||
|
// Check if error is retryable
|
||||||
|
if attempt < max_retries && Self::is_retryable_tool_error(&msg) {
|
||||||
|
last_err = msg;
|
||||||
|
let backoff_ms = 100u64.saturating_mul(2u64.pow(attempt as u32));
|
||||||
|
tracing::warn!(
|
||||||
|
tool = %name,
|
||||||
|
attempt = attempt + 1,
|
||||||
|
backoff_ms = backoff_ms,
|
||||||
|
error = %last_err,
|
||||||
|
"tool_execute_retry"
|
||||||
|
);
|
||||||
|
tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Non-retryable or exhausted retries — pass error to AI as observation
|
||||||
|
return Err(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
last_err = e.to_string();
|
||||||
|
if attempt < max_retries && Self::is_retryable_tool_error(&last_err) {
|
||||||
|
let backoff_ms = 100u64.saturating_mul(2u64.pow(attempt as u32));
|
||||||
|
tracing::warn!(
|
||||||
|
tool = %name,
|
||||||
|
attempt = attempt + 1,
|
||||||
|
backoff_ms = backoff_ms,
|
||||||
|
error = %last_err,
|
||||||
|
"tool_execute_retry"
|
||||||
|
);
|
||||||
|
tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return Err(last_err);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Should not reach here, but just in case
|
||||||
|
Err(last_err)
|
||||||
}) as Pin<Box<dyn std::future::Future<Output = std::result::Result<serde_json::Value, String>> + Send>>
|
}) as Pin<Box<dyn std::future::Future<Output = std::result::Result<serde_json::Value, String>> + Send>>
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -57,6 +57,18 @@ You must respond in JSON format:
|
|||||||
- Chain multiple tool calls if a single call is insufficient.
|
- Chain multiple tool calls if a single call is insufficient.
|
||||||
- After each tool result, re-evaluate whether more data is needed before providing a final answer.
|
- After each tool result, re-evaluate whether more data is needed before providing a final answer.
|
||||||
|
|
||||||
|
## Handling Tool Errors
|
||||||
|
|
||||||
|
When a tool returns an error observation (a JSON object with an "error" field):
|
||||||
|
- **Transient errors** (e.g., "connection refused", "not found", "timeout", "rate limit", "permission denied"): Retry with adjusted arguments, or try an alternative tool.
|
||||||
|
- **Permanent errors** (e.g., "invalid arguments", "tool not registered"): Do NOT retry — acknowledge the error and try a different approach or reformulate your question.
|
||||||
|
- **Empty results** (e.g., "no issues found"): This is NOT an error — continue with the next logical tool or provide your answer based on what was found.
|
||||||
|
|
||||||
|
The system automatically retries transient failures up to 3 times with backoff, but you should still:
|
||||||
|
1. Fix any malformed arguments before retrying.
|
||||||
|
2. If the same tool fails twice with the same error, switch to a different approach.
|
||||||
|
3. Always provide a useful answer even if all tools fail — state what you attempted and what went wrong.
|
||||||
|
|
||||||
## Principles
|
## Principles
|
||||||
|
|
||||||
- Be precise and cite specific issue/PR numbers, commit hashes, or message IDs when available.
|
- Be precise and cite specific issue/PR numbers, commit hashes, or message IDs when available.
|
||||||
|
|||||||
@ -432,6 +432,9 @@ use utoipa::OpenApi;
|
|||||||
crate::user::subscribe::get_subscribers,
|
crate::user::subscribe::get_subscribers,
|
||||||
crate::user::subscribe::get_subscription_count,
|
crate::user::subscribe::get_subscription_count,
|
||||||
crate::user::subscribe::get_subscriber_count,
|
crate::user::subscribe::get_subscriber_count,
|
||||||
|
crate::user::subscribe::get_following_list,
|
||||||
|
crate::user::user_activity::get_user_activity,
|
||||||
|
crate::user::stars::get_user_stars,
|
||||||
crate::user::user_info::get_user_info,
|
crate::user::user_info::get_user_info,
|
||||||
// Skill
|
// Skill
|
||||||
crate::skill::skill_list,
|
crate::skill::skill_list,
|
||||||
@ -623,6 +626,12 @@ use utoipa::OpenApi;
|
|||||||
service::user::repository::UserReposResponse,
|
service::user::repository::UserReposResponse,
|
||||||
service::user::repository::UserReposQuery,
|
service::user::repository::UserReposQuery,
|
||||||
service::user::subscribe::SubscriptionInfo,
|
service::user::subscribe::SubscriptionInfo,
|
||||||
|
service::user::subscribe::UserCard,
|
||||||
|
service::user::user_activity::UserActivityItem,
|
||||||
|
service::user::user_activity::UserActivityResponse,
|
||||||
|
service::user::stars::RepoStarItem,
|
||||||
|
service::user::stars::ProjectFollowItem,
|
||||||
|
service::user::stars::UserStarsResponse,
|
||||||
service::user::user_info::UserInfoExternal,
|
service::user::user_info::UserInfoExternal,
|
||||||
// Workspace
|
// Workspace
|
||||||
service::workspace::init::WorkspaceInitParams,
|
service::workspace::init::WorkspaceInitParams,
|
||||||
|
|||||||
@ -6,7 +6,9 @@ pub mod profile;
|
|||||||
pub mod projects;
|
pub mod projects;
|
||||||
pub mod repository;
|
pub mod repository;
|
||||||
pub mod ssh_key;
|
pub mod ssh_key;
|
||||||
|
pub mod stars;
|
||||||
pub mod subscribe;
|
pub mod subscribe;
|
||||||
|
pub mod user_activity;
|
||||||
pub mod user_info;
|
pub mod user_info;
|
||||||
|
|
||||||
use actix_web::web;
|
use actix_web::web;
|
||||||
@ -86,6 +88,8 @@ pub fn init_user_routes(cfg: &mut web::ServiceConfig) {
|
|||||||
web::get().to(chpc::get_contribution_heatmap),
|
web::get().to(chpc::get_contribution_heatmap),
|
||||||
)
|
)
|
||||||
.route("/{username}/keys", web::get().to(ssh_key::list_ssh_keys))
|
.route("/{username}/keys", web::get().to(ssh_key::list_ssh_keys))
|
||||||
|
.route("/{username}/activity", web::get().to(user_activity::get_user_activity))
|
||||||
|
.route("/{username}/stars", web::get().to(stars::get_user_stars))
|
||||||
.route(
|
.route(
|
||||||
"/{username}/keys/{key_id}",
|
"/{username}/keys/{key_id}",
|
||||||
web::get().to(ssh_key::get_ssh_key),
|
web::get().to(ssh_key::get_ssh_key),
|
||||||
@ -118,6 +122,10 @@ pub fn init_user_routes(cfg: &mut web::ServiceConfig) {
|
|||||||
"/{username}/following/count",
|
"/{username}/following/count",
|
||||||
web::get().to(subscribe::get_subscription_count),
|
web::get().to(subscribe::get_subscription_count),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/{username}/following",
|
||||||
|
web::get().to(subscribe::get_following_list),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/{username}/followers/count",
|
"/{username}/followers/count",
|
||||||
web::get().to(subscribe::get_subscriber_count),
|
web::get().to(subscribe::get_subscriber_count),
|
||||||
|
|||||||
25
libs/api/user/stars.rs
Normal file
25
libs/api/user/stars.rs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
use crate::{ApiResponse, error::ApiError};
|
||||||
|
use actix_web::{HttpResponse, Result, web};
|
||||||
|
use service::AppService;
|
||||||
|
use session::Session;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/users/{username}/stars",
|
||||||
|
params(("username" = String, Path)),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Get user stars", body = ApiResponse<service::user::stars::UserStarsResponse>),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 404, description = "Not found"),
|
||||||
|
),
|
||||||
|
tag = "User"
|
||||||
|
)]
|
||||||
|
pub async fn get_user_stars(
|
||||||
|
service: web::Data<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
let username = path.into_inner();
|
||||||
|
let resp = service.get_user_stars(session, username).await?;
|
||||||
|
Ok(ApiResponse::ok(resp).to_response())
|
||||||
|
}
|
||||||
@ -131,3 +131,24 @@ pub async fn get_subscriber_count(
|
|||||||
let resp = service.user_get_subscriber_count(session, username).await?;
|
let resp = service.user_get_subscriber_count(session, username).await?;
|
||||||
Ok(ApiResponse::ok(serde_json::json!({ "count": resp })).to_response())
|
Ok(ApiResponse::ok(serde_json::json!({ "count": resp })).to_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/users/{username}/following",
|
||||||
|
params(("username" = String, Path)),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "List following users", body = ApiResponse<Vec<service::user::subscribe::UserCard>>),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 404, description = "Not found"),
|
||||||
|
),
|
||||||
|
tag = "User"
|
||||||
|
)]
|
||||||
|
pub async fn get_following_list(
|
||||||
|
service: web::Data<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
let username = path.into_inner();
|
||||||
|
let resp = service.user_get_following_list(session, username).await?;
|
||||||
|
Ok(ApiResponse::ok(resp).to_response())
|
||||||
|
}
|
||||||
|
|||||||
33
libs/api/user/user_activity.rs
Normal file
33
libs/api/user/user_activity.rs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
use crate::{ApiResponse, error::ApiError};
|
||||||
|
use actix_web::{HttpResponse, Result, web};
|
||||||
|
use service::AppService;
|
||||||
|
use service::user::user_activity::UserActivityQuery;
|
||||||
|
use session::Session;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/users/{username}/activity",
|
||||||
|
params(
|
||||||
|
("username" = String, Path),
|
||||||
|
("page" = Option<u64>, Query),
|
||||||
|
("per_page" = Option<u64>, Query),
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Get user activity", body = ApiResponse<service::user::user_activity::UserActivityResponse>),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 404, description = "Not found"),
|
||||||
|
),
|
||||||
|
tag = "User"
|
||||||
|
)]
|
||||||
|
pub async fn get_user_activity(
|
||||||
|
service: web::Data<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
query: web::Query<UserActivityQuery>,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
let username = path.into_inner();
|
||||||
|
let resp = service
|
||||||
|
.get_user_activity(session, username, query.into_inner())
|
||||||
|
.await?;
|
||||||
|
Ok(ApiResponse::ok(resp).to_response())
|
||||||
|
}
|
||||||
8
libs/rpc/buf.gen.yaml
Normal file
8
libs/rpc/buf.gen.yaml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
version: v2
|
||||||
|
managed:
|
||||||
|
enabled: true
|
||||||
|
plugins:
|
||||||
|
- remote: buf.build/bufbuild/es
|
||||||
|
out: ../../admin/src/lib/adminrpc/generated
|
||||||
|
- remote: buf.build/bufbuild/connect-es
|
||||||
|
out: ../../admin/src/lib/adminrpc/generated
|
||||||
9
libs/rpc/buf.yaml
Normal file
9
libs/rpc/buf.yaml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
version: v2
|
||||||
|
name: buf.build/gitdataai/code
|
||||||
|
deps:
|
||||||
|
- buf.build/googleapis/googlerpc
|
||||||
|
lint:
|
||||||
|
use:
|
||||||
|
- DEFAULT
|
||||||
|
except:
|
||||||
|
- PACKAGE_VERSION_SUFFIX
|
||||||
@ -121,6 +121,98 @@ message ExportMetricsCsvResponse {
|
|||||||
string csv = 1;
|
string csv = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AI Model Sync
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
message SyncModelsRequest {}
|
||||||
|
message SyncModelsResponse {
|
||||||
|
string body_json = 1; // Serialized SyncModelsResponse JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Billing Alert Check
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
message CheckAlertsRequest {}
|
||||||
|
message CheckAlertsResponse {
|
||||||
|
string body_json = 1; // Serialized CheckAlertsResponse JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AI Provider CRUD
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
message CreateProviderRequest {
|
||||||
|
string body_json = 1; // Serialized AdminCreateProvider JSON
|
||||||
|
}
|
||||||
|
message UpdateProviderRequest {
|
||||||
|
string id = 1;
|
||||||
|
string body_json = 2; // Serialized AdminUpdateProvider JSON
|
||||||
|
}
|
||||||
|
message DeleteProviderRequest {
|
||||||
|
string id = 1;
|
||||||
|
}
|
||||||
|
message ProviderResponse {
|
||||||
|
string body_json = 1; // Serialized response JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AI Model CRUD
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
message CreateModelRequest {
|
||||||
|
string body_json = 1; // Serialized AdminCreateModel JSON
|
||||||
|
}
|
||||||
|
message UpdateModelRequest {
|
||||||
|
string id = 1;
|
||||||
|
string body_json = 2; // Serialized AdminUpdateModel JSON
|
||||||
|
}
|
||||||
|
message DeleteModelRequest {
|
||||||
|
string id = 1;
|
||||||
|
}
|
||||||
|
message ModelResponse {
|
||||||
|
string body_json = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AI Version CRUD
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
message CreateVersionRequest {
|
||||||
|
string body_json = 1; // Serialized AdminCreateVersion JSON
|
||||||
|
}
|
||||||
|
message UpdateVersionRequest {
|
||||||
|
string id = 1;
|
||||||
|
string body_json = 2; // Serialized AdminUpdateVersion JSON
|
||||||
|
}
|
||||||
|
message DeleteVersionRequest {
|
||||||
|
string id = 1;
|
||||||
|
}
|
||||||
|
message VersionResponse {
|
||||||
|
string body_json = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AI Pricing Update
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
message UpdatePricingRequest {
|
||||||
|
string id = 1;
|
||||||
|
string body_json = 2; // Serialized AdminUpdatePricing JSON
|
||||||
|
}
|
||||||
|
message PricingResponse {
|
||||||
|
string body_json = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Generic delete response
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
message DeleteResponse {
|
||||||
|
bool deleted = 1;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Service
|
// Service
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -136,4 +228,21 @@ service SessionAdmin {
|
|||||||
rpc IsUserOnline(IsUserOnlineRequest) returns (IsUserOnlineResponse);
|
rpc IsUserOnline(IsUserOnlineRequest) returns (IsUserOnlineResponse);
|
||||||
rpc GetMetrics(GetMetricsRequest) returns (GetMetricsResponse);
|
rpc GetMetrics(GetMetricsRequest) returns (GetMetricsResponse);
|
||||||
rpc ExportMetricsCsv(ExportMetricsCsvRequest) returns (ExportMetricsCsvResponse);
|
rpc ExportMetricsCsv(ExportMetricsCsvRequest) returns (ExportMetricsCsvResponse);
|
||||||
|
// AI
|
||||||
|
rpc SyncModels(SyncModelsRequest) returns (SyncModelsResponse);
|
||||||
|
rpc CheckAlerts(CheckAlertsRequest) returns (CheckAlertsResponse);
|
||||||
|
// AI Provider
|
||||||
|
rpc CreateProvider(CreateProviderRequest) returns (ProviderResponse);
|
||||||
|
rpc UpdateProvider(UpdateProviderRequest) returns (ProviderResponse);
|
||||||
|
rpc DeleteProvider(DeleteProviderRequest) returns (DeleteResponse);
|
||||||
|
// AI Model
|
||||||
|
rpc CreateModel(CreateModelRequest) returns (ModelResponse);
|
||||||
|
rpc UpdateModel(UpdateModelRequest) returns (ModelResponse);
|
||||||
|
rpc DeleteModel(DeleteModelRequest) returns (DeleteResponse);
|
||||||
|
// AI Version
|
||||||
|
rpc CreateVersion(CreateVersionRequest) returns (VersionResponse);
|
||||||
|
rpc UpdateVersion(UpdateVersionRequest) returns (VersionResponse);
|
||||||
|
rpc DeleteVersion(DeleteVersionRequest) returns (DeleteResponse);
|
||||||
|
// AI Pricing
|
||||||
|
rpc UpdatePricing(UpdatePricingRequest) returns (PricingResponse);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,5 +8,7 @@ pub mod profile;
|
|||||||
pub mod projects;
|
pub mod projects;
|
||||||
pub mod repository;
|
pub mod repository;
|
||||||
pub mod ssh_key;
|
pub mod ssh_key;
|
||||||
|
pub mod stars;
|
||||||
pub mod subscribe;
|
pub mod subscribe;
|
||||||
|
pub mod user_activity;
|
||||||
pub mod user_info;
|
pub mod user_info;
|
||||||
|
|||||||
@ -60,25 +60,55 @@ impl AppService {
|
|||||||
let per_page = std::cmp::Ord::min(std::cmp::Ord::max(query.per_page.unwrap_or(20), 1), 100);
|
let per_page = std::cmp::Ord::min(std::cmp::Ord::max(query.per_page.unwrap_or(20), 1), 100);
|
||||||
let offset = (page - 1) * per_page;
|
let offset = (page - 1) * per_page;
|
||||||
|
|
||||||
let mut condition = Condition::all().add(project::Column::CreatedBy.eq(target_user.uid));
|
// Projects where user is the creator
|
||||||
|
let created_projects: Vec<Uuid> = project::Entity::find()
|
||||||
if !is_owner && !has_admin_privilege {
|
.filter(project::Column::CreatedBy.eq(target_user.uid))
|
||||||
condition = condition.add(project::Column::IsPublic.eq(true));
|
.select_only()
|
||||||
}
|
.column(project::Column::Id)
|
||||||
|
.into_tuple::<Uuid>()
|
||||||
let total_count = project::Entity::find()
|
|
||||||
.filter(condition.clone())
|
|
||||||
.count(&self.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let project_list = project::Entity::find()
|
|
||||||
.filter(condition)
|
|
||||||
.order_by_desc(project::Column::CreatedAt)
|
|
||||||
.limit(per_page)
|
|
||||||
.offset(offset)
|
|
||||||
.all(&self.db)
|
.all(&self.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// Projects where user is a member (via invitation)
|
||||||
|
let member_projects: Vec<Uuid> = project_members::Entity::find()
|
||||||
|
.filter(project_members::Column::User.eq(target_user.uid))
|
||||||
|
.select_only()
|
||||||
|
.column(project_members::Column::Project)
|
||||||
|
.into_tuple::<Uuid>()
|
||||||
|
.all(&self.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Union + dedup (preserving first occurrence order)
|
||||||
|
let mut project_ids: Vec<Uuid> = created_projects;
|
||||||
|
let new_ids: Vec<Uuid> = member_projects.into_iter().filter(|id| !project_ids.contains(id)).collect();
|
||||||
|
project_ids.extend(new_ids);
|
||||||
|
|
||||||
|
let total_count = project_ids.len() as u64;
|
||||||
|
|
||||||
|
// Paginate
|
||||||
|
let page_ids: Vec<Uuid> = project_ids.into_iter().skip(offset as usize).take(per_page as usize).collect();
|
||||||
|
|
||||||
|
if page_ids.is_empty() {
|
||||||
|
return Ok(UserProjectsResponse {
|
||||||
|
username: target_user.username,
|
||||||
|
projects: vec![],
|
||||||
|
total_count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let project_list: Vec<project::Model> = project::Entity::find()
|
||||||
|
.filter(project::Column::Id.is_in(page_ids.clone()))
|
||||||
|
.all(&self.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Preserve the order from project_ids (created projects first, then member projects)
|
||||||
|
let mut sorted_projects = project_list;
|
||||||
|
sorted_projects.sort_by(|a, b| {
|
||||||
|
let a_idx = page_ids.iter().position(|&x| x == a.id).unwrap_or(usize::MAX);
|
||||||
|
let b_idx = page_ids.iter().position(|&x| x == b.id).unwrap_or(usize::MAX);
|
||||||
|
a_idx.cmp(&b_idx)
|
||||||
|
});
|
||||||
|
|
||||||
let user_project_memberships: std::collections::HashSet<Uuid> =
|
let user_project_memberships: std::collections::HashSet<Uuid> =
|
||||||
if let Some(uid) = current_user_uid {
|
if let Some(uid) = current_user_uid {
|
||||||
project_members::Entity::find()
|
project_members::Entity::find()
|
||||||
@ -95,7 +125,11 @@ impl AppService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut project_infos: Vec<UserProjectInfo> = Vec::new();
|
let mut project_infos: Vec<UserProjectInfo> = Vec::new();
|
||||||
for project in project_list {
|
for project in sorted_projects {
|
||||||
|
// Privacy: non-owners/non-admins only see public projects (member or created)
|
||||||
|
if !is_owner && !has_admin_privilege && !project.is_public {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let member_count = project_members::Entity::find()
|
let member_count = project_members::Entity::find()
|
||||||
.filter(project_members::Column::Project.eq(project.id))
|
.filter(project_members::Column::Project.eq(project.id))
|
||||||
.count(&self.db)
|
.count(&self.db)
|
||||||
|
|||||||
152
libs/service/user/stars.rs
Normal file
152
libs/service/user/stars.rs
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
use crate::AppService;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use models::projects::{project, project_follow};
|
||||||
|
use models::repos::{repo, repo_star};
|
||||||
|
use models::users::user;
|
||||||
|
use sea_orm::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use session::Session;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct RepoStarItem {
|
||||||
|
pub uid: Uuid,
|
||||||
|
pub repo_name: String,
|
||||||
|
pub owner: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub is_private: bool,
|
||||||
|
pub default_branch: String,
|
||||||
|
pub starred_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct ProjectFollowItem {
|
||||||
|
pub uid: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub is_public: bool,
|
||||||
|
pub followed_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct UserStarsResponse {
|
||||||
|
pub repos: Vec<RepoStarItem>,
|
||||||
|
pub projects: Vec<ProjectFollowItem>,
|
||||||
|
pub total: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppService {
|
||||||
|
pub async fn get_user_stars(
|
||||||
|
&self,
|
||||||
|
context: Session,
|
||||||
|
username: String,
|
||||||
|
) -> Result<UserStarsResponse, AppError> {
|
||||||
|
let target_user = user::Entity::find()
|
||||||
|
.filter(user::Column::Username.eq(&username))
|
||||||
|
.one(&self.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::UserNotFound)?;
|
||||||
|
|
||||||
|
let current_uid = context.user();
|
||||||
|
let is_owner = current_uid
|
||||||
|
.map(|uid| uid == target_user.uid)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
// Repo stars
|
||||||
|
let stars = repo_star::Entity::find()
|
||||||
|
.filter(repo_star::Column::User.eq(target_user.uid))
|
||||||
|
.order_by_desc(repo_star::Column::CreatedAt)
|
||||||
|
.all(&self.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let repo_ids: Vec<Uuid> = stars.iter().map(|s| s.repo).collect();
|
||||||
|
|
||||||
|
let repos: std::collections::HashMap<Uuid, repo::Model> = repo::Entity::find()
|
||||||
|
.filter(repo::Column::Id.is_in(repo_ids.clone()))
|
||||||
|
.all(&self.db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| (r.id, r))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let project_ids: Vec<Uuid> = repos.values().map(|r| r.project).collect();
|
||||||
|
let projects_map: std::collections::HashMap<Uuid, project::Model> = project::Entity::find()
|
||||||
|
.filter(project::Column::Id.is_in(project_ids))
|
||||||
|
.all(&self.db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| (p.id, p))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Build repo items with privacy filter
|
||||||
|
let mut repo_items: Vec<RepoStarItem> = Vec::new();
|
||||||
|
for star in &stars {
|
||||||
|
if let Some(r) = repos.get(&star.repo) {
|
||||||
|
// Privacy: non-owners can only see public repos
|
||||||
|
if !is_owner && r.is_private {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let owner_username = if let Some(p) = projects_map.get(&r.project) {
|
||||||
|
p.name.clone()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
repo_items.push(RepoStarItem {
|
||||||
|
uid: r.id,
|
||||||
|
repo_name: r.repo_name.clone(),
|
||||||
|
owner: owner_username,
|
||||||
|
description: r.description.clone(),
|
||||||
|
is_private: r.is_private,
|
||||||
|
default_branch: r.default_branch.clone(),
|
||||||
|
starred_at: star.created_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project follows
|
||||||
|
let follows = project_follow::Entity::find()
|
||||||
|
.filter(project_follow::Column::User.eq(target_user.uid))
|
||||||
|
.order_by_desc(project_follow::Column::CreatedAt)
|
||||||
|
.all(&self.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let followed_project_ids: Vec<Uuid> = follows.iter().map(|f| f.project).collect();
|
||||||
|
|
||||||
|
let followed_projects: std::collections::HashMap<Uuid, project::Model> = project::Entity::find()
|
||||||
|
.filter(project::Column::Id.is_in(followed_project_ids))
|
||||||
|
.all(&self.db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| (p.id, p))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut project_items: Vec<ProjectFollowItem> = Vec::new();
|
||||||
|
for follow in &follows {
|
||||||
|
if let Some(p) = followed_projects.get(&follow.project) {
|
||||||
|
// Privacy: non-owners can only see public projects
|
||||||
|
if !is_owner && !p.is_public {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
project_items.push(ProjectFollowItem {
|
||||||
|
uid: p.id,
|
||||||
|
name: p.name.clone(),
|
||||||
|
display_name: p.display_name.clone(),
|
||||||
|
description: p.description.clone(),
|
||||||
|
is_public: p.is_public,
|
||||||
|
followed_at: follow.created_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = repo_items.len() as u64 + project_items.len() as u64;
|
||||||
|
|
||||||
|
Ok(UserStarsResponse {
|
||||||
|
repos: repo_items,
|
||||||
|
projects: project_items,
|
||||||
|
total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,6 +15,15 @@ pub struct SubscriptionInfo {
|
|||||||
pub is_active: bool,
|
pub is_active: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
|
pub struct UserCard {
|
||||||
|
pub user_uid: Uuid,
|
||||||
|
pub username: String,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub is_following_me: bool,
|
||||||
|
}
|
||||||
|
|
||||||
impl From<user_relation::Model> for SubscriptionInfo {
|
impl From<user_relation::Model> for SubscriptionInfo {
|
||||||
fn from(sub: user_relation::Model) -> Self {
|
fn from(sub: user_relation::Model) -> Self {
|
||||||
SubscriptionInfo {
|
SubscriptionInfo {
|
||||||
@ -154,4 +163,63 @@ impl AppService {
|
|||||||
|
|
||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn user_get_following_list(
|
||||||
|
&self,
|
||||||
|
context: Session,
|
||||||
|
username: String,
|
||||||
|
) -> Result<Vec<UserCard>, AppError> {
|
||||||
|
let target_user = self.utils_find_user_by_username(username).await?;
|
||||||
|
let target_uid = target_user.uid;
|
||||||
|
let current_uid = context.user();
|
||||||
|
|
||||||
|
let following = user_relation::Entity::find()
|
||||||
|
.filter(user_relation::Column::User.eq(target_uid))
|
||||||
|
.filter(user_relation::Column::RelationType.eq("follow"))
|
||||||
|
.order_by_desc(user_relation::Column::CreatedAt)
|
||||||
|
.all(&self.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let followed_uids: Vec<Uuid> = following.iter().map(|f| f.target).collect();
|
||||||
|
|
||||||
|
let followed_users: std::collections::HashMap<Uuid, models::users::user::Model> = models::users::user::Entity::find()
|
||||||
|
.filter(models::users::user::Column::Uid.is_in(followed_uids.clone()))
|
||||||
|
.all(&self.db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|u| (u.uid, u))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// If current user is logged in, check who they also follow
|
||||||
|
let current_follows: std::collections::HashSet<Uuid> = if let Some(uid) = current_uid {
|
||||||
|
user_relation::Entity::find()
|
||||||
|
.filter(user_relation::Column::User.eq(uid))
|
||||||
|
.filter(user_relation::Column::Target.is_in(followed_uids.clone()))
|
||||||
|
.filter(user_relation::Column::RelationType.eq("follow"))
|
||||||
|
.select_only()
|
||||||
|
.column(user_relation::Column::Target)
|
||||||
|
.into_tuple::<Uuid>()
|
||||||
|
.all(&self.db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
std::collections::HashSet::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut cards: Vec<UserCard> = Vec::new();
|
||||||
|
for rel in following {
|
||||||
|
if let Some(user) = followed_users.get(&rel.target) {
|
||||||
|
cards.push(UserCard {
|
||||||
|
user_uid: user.uid,
|
||||||
|
username: user.username.clone(),
|
||||||
|
display_name: user.display_name.clone(),
|
||||||
|
avatar_url: user.avatar_url.clone(),
|
||||||
|
is_following_me: current_follows.contains(&user.uid),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(cards)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
173
libs/service/user/user_activity.rs
Normal file
173
libs/service/user/user_activity.rs
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
use crate::AppService;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use models::projects::{project, project_activity};
|
||||||
|
use models::users::{user, user_activity_log};
|
||||||
|
use sea_orm::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use session::Session;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct UserActivityItem {
|
||||||
|
pub id: i64,
|
||||||
|
pub activity_type: String, // "auth" | "project"
|
||||||
|
pub action: String,
|
||||||
|
pub title: String,
|
||||||
|
pub resource_type: Option<String>,
|
||||||
|
pub resource_name: Option<String>,
|
||||||
|
pub metadata: Option<serde_json::Value>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct UserActivityResponse {
|
||||||
|
pub items: Vec<UserActivityItem>,
|
||||||
|
pub total: u64,
|
||||||
|
pub page: u64,
|
||||||
|
pub per_page: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize, utoipa::IntoParams)]
|
||||||
|
pub struct UserActivityQuery {
|
||||||
|
pub page: Option<u64>,
|
||||||
|
pub per_page: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppService {
|
||||||
|
pub async fn get_user_activity(
|
||||||
|
&self,
|
||||||
|
context: Session,
|
||||||
|
username: String,
|
||||||
|
query: UserActivityQuery,
|
||||||
|
) -> Result<UserActivityResponse, AppError> {
|
||||||
|
let target_user = user::Entity::find()
|
||||||
|
.filter(user::Column::Username.eq(&username))
|
||||||
|
.one(&self.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::UserNotFound)?;
|
||||||
|
|
||||||
|
let current_uid = context.user();
|
||||||
|
let is_owner = current_uid
|
||||||
|
.map(|uid| uid == target_user.uid)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let page = std::cmp::Ord::max(query.page.unwrap_or(1), 1);
|
||||||
|
let per_page = std::cmp::Ord::min(std::cmp::Ord::max(query.per_page.unwrap_or(20), 1), 100);
|
||||||
|
let offset = (page - 1) * per_page;
|
||||||
|
|
||||||
|
// User's auth activity log entries
|
||||||
|
let auth_logs: Vec<(i64, String, DateTime<Utc>, serde_json::Value)> =
|
||||||
|
user_activity_log::Entity::find()
|
||||||
|
.filter(user_activity_log::Column::UserUid.eq(target_user.uid))
|
||||||
|
.order_by_desc(user_activity_log::Column::CreatedAt)
|
||||||
|
.select_only()
|
||||||
|
.column(user_activity_log::Column::Id)
|
||||||
|
.column(user_activity_log::Column::Action)
|
||||||
|
.column(user_activity_log::Column::CreatedAt)
|
||||||
|
.column(user_activity_log::Column::Details)
|
||||||
|
.into_tuple()
|
||||||
|
.all(&self.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// User's project activity (where user is the actor)
|
||||||
|
let proj_activity = project_activity::Entity::find()
|
||||||
|
.filter(project_activity::Column::Actor.eq(target_user.uid))
|
||||||
|
.order_by_desc(project_activity::Column::CreatedAt)
|
||||||
|
.all(&self.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Privacy filter: non-owners only see public project activity
|
||||||
|
let proj_ids: Vec<Uuid> = proj_activity.iter().map(|a| a.project).collect();
|
||||||
|
let proj_map: std::collections::HashMap<Uuid, project::Model> = project::Entity::find()
|
||||||
|
.filter(project::Column::Id.is_in(proj_ids))
|
||||||
|
.all(&self.db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| (p.id, p))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Build combined activity list
|
||||||
|
let mut items: Vec<UserActivityItem> = Vec::new();
|
||||||
|
|
||||||
|
// Auth events
|
||||||
|
for (id, action, created_at, metadata) in auth_logs {
|
||||||
|
let title = format_action_title(&action);
|
||||||
|
items.push(UserActivityItem {
|
||||||
|
id,
|
||||||
|
activity_type: "auth".to_string(),
|
||||||
|
action,
|
||||||
|
title,
|
||||||
|
resource_type: None,
|
||||||
|
resource_name: None,
|
||||||
|
metadata: if metadata != serde_json::Value::Null {
|
||||||
|
Some(metadata)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
created_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project events
|
||||||
|
for activity in proj_activity {
|
||||||
|
// Privacy filter
|
||||||
|
if !is_owner {
|
||||||
|
if let Some(proj) = proj_map.get(&activity.project) {
|
||||||
|
if proj.is_public == false {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items.push(UserActivityItem {
|
||||||
|
id: activity.id,
|
||||||
|
activity_type: "project".to_string(),
|
||||||
|
action: activity.event_type,
|
||||||
|
title: activity.title,
|
||||||
|
resource_type: Some("project".to_string()),
|
||||||
|
resource_name: proj_map
|
||||||
|
.get(&activity.project)
|
||||||
|
.map(|p| p.name.clone()),
|
||||||
|
metadata: activity.metadata,
|
||||||
|
created_at: activity.created_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by created_at DESC
|
||||||
|
items.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||||
|
|
||||||
|
let total = items.len() as u64;
|
||||||
|
let page_items: Vec<UserActivityItem> = items.into_iter().skip(offset as usize).take(per_page as usize).collect();
|
||||||
|
|
||||||
|
Ok(UserActivityResponse {
|
||||||
|
items: page_items,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
per_page,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_action_title(action: &str) -> String {
|
||||||
|
match action {
|
||||||
|
"login" => "Signed in".to_string(),
|
||||||
|
"logout" => "Signed out".to_string(),
|
||||||
|
"register" => "Created account".to_string(),
|
||||||
|
"password_change" => "Changed password".to_string(),
|
||||||
|
"password_reset" => "Reset password".to_string(),
|
||||||
|
"2fa_enabled" => "Enabled 2FA".to_string(),
|
||||||
|
"2fa_disabled" => "Disabled 2FA".to_string(),
|
||||||
|
"ssh_key_add" => "Added SSH key".to_string(),
|
||||||
|
"ssh_key_delete" => "Removed SSH key".to_string(),
|
||||||
|
"ssh_key_update" => "Updated SSH key".to_string(),
|
||||||
|
"ssh_key_revoke" => "Revoked SSH key".to_string(),
|
||||||
|
"access_key_create" => "Created access token".to_string(),
|
||||||
|
"access_key_delete" => "Deleted access token".to_string(),
|
||||||
|
"avatar_upload" => "Updated avatar".to_string(),
|
||||||
|
"profile_update" => "Updated profile".to_string(),
|
||||||
|
_ => format!("Activity: {}", action),
|
||||||
|
}
|
||||||
|
}
|
||||||
1360
openapi.json
1360
openapi.json
File diff suppressed because it is too large
Load Diff
@ -21,7 +21,7 @@ const TAG = process.env.TAG || GIT_SHA_SHORT;
|
|||||||
const DOCKER_USER = process.env.DOCKER_USER || process.env.HARBOR_USERNAME;
|
const DOCKER_USER = process.env.DOCKER_USER || process.env.HARBOR_USERNAME;
|
||||||
const DOCKER_PASS = process.env.DOCKER_PASS || process.env.HARBOR_PASSWORD;
|
const DOCKER_PASS = process.env.DOCKER_PASS || process.env.HARBOR_PASSWORD;
|
||||||
|
|
||||||
const SERVICES = ['app', 'gitserver', 'email-worker', 'git-hook', 'operator', 'static'];
|
const SERVICES = ['app', 'gitserver', 'email-worker', 'git-hook', 'operator', 'static', 'adminrpc'];
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const targets = args.length > 0 ? args : SERVICES;
|
const targets = args.length > 0 ? args : SERVICES;
|
||||||
|
|||||||
78
src/app/user/user-activity.tsx
Normal file
78
src/app/user/user-activity.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Activity, Clock } from 'lucide-react';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { getUserActivity, type UserActivityItem } from '@/client';
|
||||||
|
import { formatDate } from './utils';
|
||||||
|
|
||||||
|
export function UserActivity({ username }: { username: string }) {
|
||||||
|
const { data, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['user-activity', username],
|
||||||
|
queryFn: async () => {
|
||||||
|
const resp = await getUserActivity({ path: { username } });
|
||||||
|
return resp.data?.data ?? null;
|
||||||
|
},
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card className="border-border/40">
|
||||||
|
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Loading activity...
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return (
|
||||||
|
<Card className="border-border/40">
|
||||||
|
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Unable to load activity.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.items.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="border-border/40">
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<Activity className="mx-auto h-8 w-8 text-muted-foreground/40" />
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">No activity yet</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTypeColor = (type: string) => {
|
||||||
|
if (type === 'auth') return 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400';
|
||||||
|
return 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{data.items.map((item: UserActivityItem) => (
|
||||||
|
<Card key={`${item.activity_type}-${item.id}`} className="border-border/40">
|
||||||
|
<CardContent className="flex items-start gap-3 py-4">
|
||||||
|
<span className={`mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-medium ${getTypeColor(item.activity_type)}`}>
|
||||||
|
{item.activity_type === 'auth' ? 'A' : 'P'}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium">{item.title}</p>
|
||||||
|
{item.resource_name && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Project: {item.resource_name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1 mt-1 text-xs text-muted-foreground/70">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{formatDate(item.created_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
src/app/user/user-following.tsx
Normal file
71
src/app/user/user-following.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { getFollowingList } from '@/client';
|
||||||
|
import { UserPlus } from 'lucide-react';
|
||||||
|
|
||||||
|
export function FollowingList({ username }: { username: string }) {
|
||||||
|
const { data, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['user-following', username],
|
||||||
|
queryFn: async () => {
|
||||||
|
const resp = await getFollowingList({ path: { username } });
|
||||||
|
return resp.data?.data ?? [];
|
||||||
|
},
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card className="border-border/40">
|
||||||
|
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Loading following...
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return (
|
||||||
|
<Card className="border-border/40">
|
||||||
|
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Unable to load following.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="border-border/40">
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<UserPlus className="mx-auto h-8 w-8 text-muted-foreground/40" />
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">Not following anyone yet</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
|
||||||
|
{data.map((user: { user_uid: string; username: string; display_name?: string | null; avatar_url?: string | null }) => (
|
||||||
|
<Link
|
||||||
|
key={user.user_uid}
|
||||||
|
to={`/user/${user.username}`}
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-border/40 p-3 transition-all hover:border-border hover:bg-muted/30"
|
||||||
|
>
|
||||||
|
<Avatar className="h-10 w-10">
|
||||||
|
<AvatarImage src={user.avatar_url ?? undefined} />
|
||||||
|
<AvatarFallback className="text-sm">
|
||||||
|
{(user.display_name || user.username).charAt(0).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">{user.display_name || user.username}</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">@{user.username}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
149
src/app/user/user-security.tsx
Normal file
149
src/app/user/user-security.tsx
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Copy, Key, Shield, Loader2, AlertTriangle } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { listSshKeys, listAccessKeys } from '@/client';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export function SecurityTab() {
|
||||||
|
const { data: sshKeys, isLoading: sshLoading } = useQuery({
|
||||||
|
queryKey: ['my-ssh-keys'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const resp = await listSshKeys();
|
||||||
|
return resp.data?.data?.keys ?? [];
|
||||||
|
},
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: accessKeys, isLoading: accessLoading } = useQuery({
|
||||||
|
queryKey: ['my-access-keys'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const resp = await listAccessKeys();
|
||||||
|
return resp.data?.data?.access_keys ?? [];
|
||||||
|
},
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLoading = sshLoading || accessLoading;
|
||||||
|
|
||||||
|
const copyFingerprint = (fingerprint: string) => {
|
||||||
|
navigator.clipboard.writeText(fingerprint).then(() => {
|
||||||
|
toast.success('Copied fingerprint');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card className="border-border/40">
|
||||||
|
<CardContent className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Loading security settings...
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* SSH Keys */}
|
||||||
|
<Card className="border-border/40">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<Key className="h-4 w-4" />
|
||||||
|
SSH Keys
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{(sshKeys ?? []).length === 0 ? (
|
||||||
|
<p className="py-4 text-center text-sm text-muted-foreground">No SSH keys added</p>
|
||||||
|
) : (
|
||||||
|
(sshKeys ?? []).map((key: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
fingerprint: string;
|
||||||
|
key_type: string;
|
||||||
|
is_revoked: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
key={key.id}
|
||||||
|
className="flex items-center justify-between rounded-lg border border-border/40 p-3"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-medium">{key.title}</p>
|
||||||
|
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase">{key.key_type}</span>
|
||||||
|
{key.is_revoked && (
|
||||||
|
<span className="flex items-center gap-1 text-[10px] text-red-500">
|
||||||
|
<AlertTriangle className="h-3 w-3" /> Revoked
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => copyFingerprint(key.fingerprint)}
|
||||||
|
className="mt-1 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{key.fingerprint}
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<Button variant="outline" size="sm" className="mt-2 w-full" onClick={() => window.location.href = '/settings/keys'}>
|
||||||
|
Manage SSH Keys
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Personal Access Tokens */}
|
||||||
|
<Card className="border-border/40">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
Personal Access Tokens
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{(accessKeys ?? []).length === 0 ? (
|
||||||
|
<p className="py-4 text-center text-sm text-muted-foreground">No access tokens created</p>
|
||||||
|
) : (
|
||||||
|
(accessKeys ?? []).map((key: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
scopes: string[];
|
||||||
|
is_revoked: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
key={key.id}
|
||||||
|
className="flex items-center justify-between rounded-lg border border-border/40 p-3"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-medium">{key.name}</p>
|
||||||
|
{key.is_revoked && (
|
||||||
|
<span className="flex items-center gap-1 text-[10px] text-red-500">
|
||||||
|
<AlertTriangle className="h-3 w-3" /> Revoked
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{(key.scopes ?? []).map((scope: string) => (
|
||||||
|
<span key={scope} className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||||
|
{scope}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<Button variant="outline" size="sm" className="mt-2 w-full" onClick={() => window.location.href = '/settings/tokens'}>
|
||||||
|
Manage Access Tokens
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
src/app/user/user-stars.tsx
Normal file
107
src/app/user/user-stars.tsx
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Star, FolderGit2, Loader2 } from 'lucide-react';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { getUserStars } from '@/client';
|
||||||
|
|
||||||
|
export function StarsList({ username }: { username: string }) {
|
||||||
|
const { data, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['user-stars', username],
|
||||||
|
queryFn: async () => {
|
||||||
|
const resp = await getUserStars({ path: { username } });
|
||||||
|
return resp.data?.data ?? null;
|
||||||
|
},
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card className="border-border/40">
|
||||||
|
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="mx-auto h-4 w-4 animate-spin" />
|
||||||
|
Loading stars...
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return (
|
||||||
|
<Card className="border-border/40">
|
||||||
|
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Unable to load stars.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRepos = (data.repos ?? []).length > 0;
|
||||||
|
const hasProjects = (data.projects ?? []).length > 0;
|
||||||
|
|
||||||
|
if (!hasRepos && !hasProjects) {
|
||||||
|
return (
|
||||||
|
<Card className="border-border/40">
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<Star className="mx-auto h-8 w-8 text-muted-foreground/40" />
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">No starred repos or projects yet</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{hasRepos && (
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-3 text-sm font-semibold text-foreground">Repositories</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(data.repos ?? []).map((repo: { uid: string; repo_name: string; owner: string; description?: string | null; is_private: boolean }) => (
|
||||||
|
<Link
|
||||||
|
key={repo.uid}
|
||||||
|
to={`/repository/${repo.owner}/${repo.repo_name}`}
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-border/40 p-3 transition-all hover:border-border hover:bg-muted/30"
|
||||||
|
>
|
||||||
|
<Star className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium">{repo.owner}/{repo.repo_name}</p>
|
||||||
|
{repo.description && (
|
||||||
|
<p className="truncate text-xs text-muted-foreground">{repo.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{repo.is_private && (
|
||||||
|
<span className="shrink-0 rounded border border-border/40 px-1.5 py-0.5 text-[10px] text-muted-foreground">Private</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasProjects && (
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-3 text-sm font-semibold text-foreground">Projects</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(data.projects ?? []).map((proj: { uid: string; name: string; display_name: string; description?: string | null; is_public: boolean }) => (
|
||||||
|
<Link
|
||||||
|
key={proj.uid}
|
||||||
|
to={`/project/${proj.name}`}
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-border/40 p-3 transition-all hover:border-border hover:bg-muted/30"
|
||||||
|
>
|
||||||
|
<FolderGit2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium">{proj.display_name}</p>
|
||||||
|
{proj.description && (
|
||||||
|
<p className="truncate text-xs text-muted-foreground">{proj.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!proj.is_public && (
|
||||||
|
<span className="shrink-0 rounded border border-border/40 px-1.5 py-0.5 text-[10px] text-muted-foreground">Private</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,7 +1,8 @@
|
|||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
|
Activity,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Building2,
|
Building2,
|
||||||
Calendar,
|
Calendar,
|
||||||
@ -10,6 +11,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
MapPin,
|
MapPin,
|
||||||
Settings,
|
Settings,
|
||||||
|
Shield,
|
||||||
Star,
|
Star,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
UserRoundCheck,
|
UserRoundCheck,
|
||||||
@ -22,6 +24,11 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { getContributionHeatmap, getSubscriberCount, getUserInfo, getUserProjects, getUserRepos, subscribeTarget, unsubscribeTarget } from '@/client';
|
import { getContributionHeatmap, getSubscriberCount, getUserInfo, getUserProjects, getUserRepos, subscribeTarget, unsubscribeTarget } from '@/client';
|
||||||
import { UserContext } from '@/contexts/user-context';
|
import { UserContext } from '@/contexts/user-context';
|
||||||
|
import { UserActivity } from './user-activity';
|
||||||
|
import { FollowingList } from './user-following';
|
||||||
|
import { StarsList } from './user-stars';
|
||||||
|
import { SecurityTab } from './user-security';
|
||||||
|
import { formatDate } from './utils';
|
||||||
|
|
||||||
const resolveCount = (payload: unknown): number => {
|
const resolveCount = (payload: unknown): number => {
|
||||||
if (typeof payload === 'number') return payload;
|
if (typeof payload === 'number') return payload;
|
||||||
@ -43,17 +50,6 @@ const resolveCount = (payload: unknown): number => {
|
|||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (value?: string | null) => {
|
|
||||||
if (!value) return '-';
|
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) return '-';
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveHeatLevel = (count: number, max: number) => {
|
const resolveHeatLevel = (count: number, max: number) => {
|
||||||
if (count <= 0) return 0;
|
if (count <= 0) return 0;
|
||||||
if (max <= 1) return 4;
|
if (max <= 1) return 4;
|
||||||
@ -246,6 +242,10 @@ export function UserProfile() {
|
|||||||
const isAuth = currentUser !== null;
|
const isAuth = currentUser !== null;
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const activeTab = searchParams.get('tab') || 'overview';
|
||||||
|
|
||||||
|
const setTab = (tab: string) => setSearchParams({ tab });
|
||||||
|
|
||||||
const userInfoKey = ['user-info', targetUser] as const;
|
const userInfoKey = ['user-info', targetUser] as const;
|
||||||
const subscriberCountKey = ['user-subscriber-count', targetUser] as const;
|
const subscriberCountKey = ['user-subscriber-count', targetUser] as const;
|
||||||
@ -537,7 +537,83 @@ export function UserProfile() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Contribution Heatmap */}
|
{/* Tab Navigation */}
|
||||||
|
<div className="flex items-center gap-1 border-b border-border/40 -mb-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setTab('overview')}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === 'overview'
|
||||||
|
? 'border-primary text-foreground'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FolderGit2 className="h-4 w-4" />
|
||||||
|
Overview
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTab('activity')}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === 'activity'
|
||||||
|
? 'border-primary text-foreground'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Activity className="h-4 w-4" />
|
||||||
|
Activity
|
||||||
|
</button>
|
||||||
|
{/*
|
||||||
|
<button
|
||||||
|
onClick={() => setTab('followers')}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === 'followers'
|
||||||
|
? 'border-primary text-foreground'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<UserCheck className="h-4 w-4" />
|
||||||
|
Followers
|
||||||
|
</button>
|
||||||
|
*/}
|
||||||
|
<button
|
||||||
|
onClick={() => setTab('following')}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === 'following'
|
||||||
|
? 'border-primary text-foreground'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<UserPlus className="h-4 w-4" />
|
||||||
|
Following
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTab('stars')}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === 'stars'
|
||||||
|
? 'border-primary text-foreground'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Star className="h-4 w-4" />
|
||||||
|
Stars
|
||||||
|
</button>
|
||||||
|
{userInfo.is_owner && (
|
||||||
|
<button
|
||||||
|
onClick={() => setTab('security')}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === 'security'
|
||||||
|
? 'border-primary text-foreground'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
Security
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
{activeTab === 'overview' && (
|
||||||
|
<div>{/* Contribution Heatmap */}
|
||||||
<ContributionHeatmap
|
<ContributionHeatmap
|
||||||
totalContributions={contributionHeatmap?.total_contributions ?? 0}
|
totalContributions={contributionHeatmap?.total_contributions ?? 0}
|
||||||
heatmap={contributionHeatmap?.heatmap ?? []}
|
heatmap={contributionHeatmap?.heatmap ?? []}
|
||||||
@ -631,6 +707,20 @@ export function UserProfile() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeTab === 'activity' && (
|
||||||
|
<UserActivity username={targetUser} />
|
||||||
|
)}
|
||||||
|
{activeTab === 'following' && (
|
||||||
|
<FollowingList username={targetUser} />
|
||||||
|
)}
|
||||||
|
{activeTab === 'stars' && (
|
||||||
|
<StarsList username={targetUser} />
|
||||||
|
)}
|
||||||
|
{activeTab === 'security' && userInfo.is_owner && (
|
||||||
|
<SecurityTab />
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
10
src/app/user/utils.ts
Normal file
10
src/app/user/utils.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export const formatDate = (value?: string | null) => {
|
||||||
|
if (!value) return '-';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return '-';
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -56,6 +56,15 @@ export type AddSshKeyParams = {
|
|||||||
public_key: string;
|
public_key: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AlertDetail = {
|
||||||
|
workspace_id: string;
|
||||||
|
workspace_name: string;
|
||||||
|
alert_type: string;
|
||||||
|
threshold: number;
|
||||||
|
current_value: number;
|
||||||
|
recipients: Array<string>;
|
||||||
|
};
|
||||||
|
|
||||||
export type AnswerRequest = {
|
export type AnswerRequest = {
|
||||||
question: string;
|
question: string;
|
||||||
answer: string;
|
answer: string;
|
||||||
@ -478,6 +487,16 @@ export type ApiResponseCardResponse = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ApiResponseCheckAlertsResponse = {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data?: {
|
||||||
|
workspaces_checked: number;
|
||||||
|
alerts_sent: number;
|
||||||
|
details: Array<AlertDetail>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type ApiResponseColumnResponse = {
|
export type ApiResponseColumnResponse = {
|
||||||
code: number;
|
code: number;
|
||||||
message: string;
|
message: string;
|
||||||
@ -1467,6 +1486,7 @@ export type ApiResponseRoomAiResponse = {
|
|||||||
think: boolean;
|
think: boolean;
|
||||||
stream: boolean;
|
stream: boolean;
|
||||||
min_score?: number | null;
|
min_score?: number | null;
|
||||||
|
agent_type?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
@ -1529,6 +1549,11 @@ export type ApiResponseRoomMessageResponse = {
|
|||||||
send_at: string;
|
send_at: string;
|
||||||
revoked?: string | null;
|
revoked?: string | null;
|
||||||
revoked_by?: string | null;
|
revoked_by?: string | null;
|
||||||
|
/**
|
||||||
|
* Highlighted content with <mark> tags around matched terms (for search results)
|
||||||
|
*/
|
||||||
|
highlighted_content?: string | null;
|
||||||
|
attachment_ids?: Array<string>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1680,6 +1705,19 @@ export type ApiResponseString = {
|
|||||||
data?: string;
|
data?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ApiResponseSyncModelsResponse = {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data?: {
|
||||||
|
models_created: number;
|
||||||
|
models_updated: number;
|
||||||
|
versions_created: number;
|
||||||
|
pricing_created: number;
|
||||||
|
capabilities_created: number;
|
||||||
|
profiles_created: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type ApiResponseTagCountResponse = {
|
export type ApiResponseTagCountResponse = {
|
||||||
code: number;
|
code: number;
|
||||||
message: string;
|
message: string;
|
||||||
@ -1828,6 +1866,17 @@ export type ApiResponseTreeIsEmptyResponse = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ApiResponseUserActivityResponse = {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data?: {
|
||||||
|
items: Array<UserActivityItem>;
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
per_page: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type ApiResponseUserInfoExternal = {
|
export type ApiResponseUserInfoExternal = {
|
||||||
code: number;
|
code: number;
|
||||||
message: string;
|
message: string;
|
||||||
@ -1869,6 +1918,16 @@ export type ApiResponseUserReposResponse = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ApiResponseUserStarsResponse = {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data?: {
|
||||||
|
repos: Array<RepoStarItem>;
|
||||||
|
projects: Array<ProjectFollowItem>;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type ApiResponseValue = {
|
export type ApiResponseValue = {
|
||||||
code: number;
|
code: number;
|
||||||
message: string;
|
message: string;
|
||||||
@ -2075,6 +2134,20 @@ export type ApiResponseVecMergeheadInfoResponse = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ApiResponseVecMyWorkspaceInvitation = {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data?: Array<{
|
||||||
|
workspace_id: string;
|
||||||
|
workspace_slug: string;
|
||||||
|
workspace_name: string;
|
||||||
|
role: string;
|
||||||
|
invited_by_username?: string | null;
|
||||||
|
invited_at: string;
|
||||||
|
expires_at?: string | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
export type ApiResponseVecPendingInvitationInfo = {
|
export type ApiResponseVecPendingInvitationInfo = {
|
||||||
code: number;
|
code: number;
|
||||||
message: string;
|
message: string;
|
||||||
@ -2124,6 +2197,7 @@ export type ApiResponseVecRoomAiResponse = {
|
|||||||
think: boolean;
|
think: boolean;
|
||||||
stream: boolean;
|
stream: boolean;
|
||||||
min_score?: number | null;
|
min_score?: number | null;
|
||||||
|
agent_type?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}>;
|
}>;
|
||||||
@ -2268,6 +2342,18 @@ export type ApiResponseVecTreeEntryResponse = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ApiResponseVecUserCard = {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data?: Array<{
|
||||||
|
user_uid: string;
|
||||||
|
username: string;
|
||||||
|
display_name?: string | null;
|
||||||
|
avatar_url?: string | null;
|
||||||
|
is_following_me: boolean;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
export type ApiResponseVecWatchUserInfo = {
|
export type ApiResponseVecWatchUserInfo = {
|
||||||
code: number;
|
code: number;
|
||||||
message: string;
|
message: string;
|
||||||
@ -2722,6 +2808,12 @@ export type ChangePasswordParams = {
|
|||||||
new_password: string;
|
new_password: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CheckAlertsResponse = {
|
||||||
|
workspaces_checked: number;
|
||||||
|
alerts_sent: number;
|
||||||
|
details: Array<AlertDetail>;
|
||||||
|
};
|
||||||
|
|
||||||
export type ColumnResponse = {
|
export type ColumnResponse = {
|
||||||
id: string;
|
id: string;
|
||||||
board: string;
|
board: string;
|
||||||
@ -2992,6 +3084,11 @@ export type ConfigSnapshotResponse = {
|
|||||||
entries: Array<ConfigEntryResponse>;
|
entries: Array<ConfigEntryResponse>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ConfirmResetPasswordParams = {
|
||||||
|
token: string;
|
||||||
|
new_password: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ContextMe = {
|
export type ContextMe = {
|
||||||
uid: string;
|
uid: string;
|
||||||
username: string;
|
username: string;
|
||||||
@ -3199,11 +3296,6 @@ export type DiffStatsResponse = {
|
|||||||
deletions: number;
|
deletions: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Disable2FaParams = {
|
|
||||||
code: string;
|
|
||||||
password: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type EmailChangeRequest = {
|
export type EmailChangeRequest = {
|
||||||
new_email: string;
|
new_email: string;
|
||||||
password: string;
|
password: string;
|
||||||
@ -3217,12 +3309,6 @@ export type EmailVerifyRequest = {
|
|||||||
token: string;
|
token: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Enable2FaResponse = {
|
|
||||||
secret: string;
|
|
||||||
qr_code: string;
|
|
||||||
backup_codes: Array<string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ExchangeProjectName = {
|
export type ExchangeProjectName = {
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
@ -3262,12 +3348,6 @@ export type GeneratePrDescriptionResponse = {
|
|||||||
billing?: null | BillingRecord;
|
billing?: null | BillingRecord;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Get2FaStatusResponse = {
|
|
||||||
is_enabled: boolean;
|
|
||||||
method?: string | null;
|
|
||||||
has_backup_codes: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GitInitRequest = {
|
export type GitInitRequest = {
|
||||||
path: string;
|
path: string;
|
||||||
bare?: boolean;
|
bare?: boolean;
|
||||||
@ -3552,7 +3632,6 @@ export type LoginParams = {
|
|||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
captcha: string;
|
captcha: string;
|
||||||
totp_code?: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MemberInfo = {
|
export type MemberInfo = {
|
||||||
@ -3742,6 +3821,19 @@ export type MoveCardParams = {
|
|||||||
position: number;
|
position: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invitation received by the current user (workspace invitation for self).
|
||||||
|
*/
|
||||||
|
export type MyWorkspaceInvitation = {
|
||||||
|
workspace_id: string;
|
||||||
|
workspace_slug: string;
|
||||||
|
workspace_name: string;
|
||||||
|
role: string;
|
||||||
|
invited_by_username?: string | null;
|
||||||
|
invited_at: string;
|
||||||
|
expires_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type NotificationListResponse = {
|
export type NotificationListResponse = {
|
||||||
notifications: Array<NotificationResponse>;
|
notifications: Array<NotificationResponse>;
|
||||||
total: number;
|
total: number;
|
||||||
@ -3759,6 +3851,18 @@ export type NotificationPreferencesParams = {
|
|||||||
marketing_enabled?: boolean | null;
|
marketing_enabled?: boolean | null;
|
||||||
security_enabled?: boolean | null;
|
security_enabled?: boolean | null;
|
||||||
product_enabled?: boolean | null;
|
product_enabled?: boolean | null;
|
||||||
|
/**
|
||||||
|
* Web Push subscription endpoint (set to null to unsubscribe)
|
||||||
|
*/
|
||||||
|
push_subscription_endpoint?: string | null;
|
||||||
|
/**
|
||||||
|
* Web Push subscription p256dh key
|
||||||
|
*/
|
||||||
|
push_subscription_keys_p256dh?: string | null;
|
||||||
|
/**
|
||||||
|
* Web Push subscription auth key
|
||||||
|
*/
|
||||||
|
push_subscription_keys_auth?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NotificationPreferencesResponse = {
|
export type NotificationPreferencesResponse = {
|
||||||
@ -3797,7 +3901,7 @@ export type NotificationResponse = {
|
|||||||
expires_at?: string | null;
|
expires_at?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NotificationType = 'mention' | 'invitation' | 'role_change' | 'room_created' | 'room_deleted' | 'system_announcement';
|
export type NotificationType = 'mention' | 'invitation' | 'role_change' | 'room_created' | 'room_deleted' | 'system_announcement' | 'project_invitation' | 'workspace_invitation';
|
||||||
|
|
||||||
export type Pager = {
|
export type Pager = {
|
||||||
page: number;
|
page: number;
|
||||||
@ -3816,16 +3920,6 @@ export type PendingInvitationInfo = {
|
|||||||
expires_at?: string | null;
|
expires_at?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MyWorkspaceInvitation = {
|
|
||||||
workspace_id: string;
|
|
||||||
workspace_slug: string;
|
|
||||||
workspace_name: string;
|
|
||||||
role: string;
|
|
||||||
invited_by_username?: string | null;
|
|
||||||
invited_at: string;
|
|
||||||
expires_at?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PrCommitResponse = {
|
export type PrCommitResponse = {
|
||||||
oid: string;
|
oid: string;
|
||||||
short_oid: string;
|
short_oid: string;
|
||||||
@ -3936,6 +4030,15 @@ export type ProjectBillingHistoryResponse = {
|
|||||||
list: Array<ProjectBillingHistoryItem>;
|
list: Array<ProjectBillingHistoryItem>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProjectFollowItem = {
|
||||||
|
uid: string;
|
||||||
|
name: string;
|
||||||
|
display_name: string;
|
||||||
|
description?: string | null;
|
||||||
|
is_public: boolean;
|
||||||
|
followed_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ProjectInfoKeyValue = {
|
export type ProjectInfoKeyValue = {
|
||||||
key: string;
|
key: string;
|
||||||
value: string;
|
value: string;
|
||||||
@ -4201,13 +4304,18 @@ export type RepoSearchItem = {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ResetPasswordParams = {
|
export type RepoStarItem = {
|
||||||
email: string;
|
uid: string;
|
||||||
|
repo_name: string;
|
||||||
|
owner: string;
|
||||||
|
description?: string | null;
|
||||||
|
is_private: boolean;
|
||||||
|
default_branch: string;
|
||||||
|
starred_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ConfirmResetPasswordParams = {
|
export type ResetPasswordParams = {
|
||||||
token: string;
|
email: string;
|
||||||
new_password: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ReviewCommentCreateRequest = {
|
export type ReviewCommentCreateRequest = {
|
||||||
@ -4348,7 +4456,7 @@ export type ReviewerInfo = {
|
|||||||
export type RoomAiResponse = {
|
export type RoomAiResponse = {
|
||||||
room: string;
|
room: string;
|
||||||
model: string;
|
model: string;
|
||||||
model_name?: string;
|
model_name?: string | null;
|
||||||
version?: string | null;
|
version?: string | null;
|
||||||
call_count: number;
|
call_count: number;
|
||||||
last_call_at?: string | null;
|
last_call_at?: string | null;
|
||||||
@ -4436,7 +4544,7 @@ export type RoomMessageCreateRequest = {
|
|||||||
content_type?: string | null;
|
content_type?: string | null;
|
||||||
thread_id?: string | null;
|
thread_id?: string | null;
|
||||||
in_reply_to?: string | null;
|
in_reply_to?: string | null;
|
||||||
attachment_ids?: string[];
|
attachment_ids?: Array<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RoomMessageListResponse = {
|
export type RoomMessageListResponse = {
|
||||||
@ -4459,7 +4567,11 @@ export type RoomMessageResponse = {
|
|||||||
send_at: string;
|
send_at: string;
|
||||||
revoked?: string | null;
|
revoked?: string | null;
|
||||||
revoked_by?: string | null;
|
revoked_by?: string | null;
|
||||||
attachment_ids?: string[];
|
/**
|
||||||
|
* Highlighted content with <mark> tags around matched terms (for search results)
|
||||||
|
*/
|
||||||
|
highlighted_content?: string | null;
|
||||||
|
attachment_ids?: Array<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RoomMessageUpdateRequest = {
|
export type RoomMessageUpdateRequest = {
|
||||||
@ -4700,6 +4812,15 @@ export type SubscriptionInfo = {
|
|||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SyncModelsResponse = {
|
||||||
|
models_created: number;
|
||||||
|
models_updated: number;
|
||||||
|
versions_created: number;
|
||||||
|
pricing_created: number;
|
||||||
|
capabilities_created: number;
|
||||||
|
profiles_created: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type TagCountResponse = {
|
export type TagCountResponse = {
|
||||||
count: number;
|
count: number;
|
||||||
};
|
};
|
||||||
@ -4945,6 +5066,32 @@ export type UpdateWebhookParams = {
|
|||||||
active?: boolean | null;
|
active?: boolean | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UserActivityItem = {
|
||||||
|
id: number;
|
||||||
|
activity_type: string;
|
||||||
|
action: string;
|
||||||
|
title: string;
|
||||||
|
resource_type?: string | null;
|
||||||
|
resource_name?: string | null;
|
||||||
|
metadata?: unknown;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserActivityResponse = {
|
||||||
|
items: Array<UserActivityItem>;
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
per_page: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserCard = {
|
||||||
|
user_uid: string;
|
||||||
|
username: string;
|
||||||
|
display_name?: string | null;
|
||||||
|
avatar_url?: string | null;
|
||||||
|
is_following_me: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type UserInfo = {
|
export type UserInfo = {
|
||||||
uid: string;
|
uid: string;
|
||||||
username: string;
|
username: string;
|
||||||
@ -5023,8 +5170,10 @@ export type UserSearchItem = {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Verify2FaParams = {
|
export type UserStarsResponse = {
|
||||||
code: string;
|
repos: Array<RepoStarItem>;
|
||||||
|
projects: Array<ProjectFollowItem>;
|
||||||
|
total: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WatchCountResponse = {
|
export type WatchCountResponse = {
|
||||||
@ -5067,6 +5216,13 @@ export type WebhookResponse = {
|
|||||||
touch_count: number;
|
touch_count: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request body for accepting workspace invitation by slug.
|
||||||
|
*/
|
||||||
|
export type WorkspaceAcceptBySlugParams = {
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type WorkspaceActivityItem = {
|
export type WorkspaceActivityItem = {
|
||||||
id: number;
|
id: number;
|
||||||
project_name: string;
|
project_name: string;
|
||||||
@ -5148,10 +5304,6 @@ export type WorkspaceInviteParams = {
|
|||||||
role?: string | null;
|
role?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkspaceAcceptBySlugParams = {
|
|
||||||
slug: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkspaceListItem = {
|
export type WorkspaceListItem = {
|
||||||
id: string;
|
id: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
@ -5225,6 +5377,92 @@ export type WorkspaceUpdateParams = {
|
|||||||
billing_email?: string | null;
|
billing_email?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AdminSyncModelsData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/api/admin/ai/sync';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminSyncModelsErrors = {
|
||||||
|
/**
|
||||||
|
* Invalid or missing admin API key
|
||||||
|
*/
|
||||||
|
401: unknown;
|
||||||
|
/**
|
||||||
|
* Sync failed
|
||||||
|
*/
|
||||||
|
500: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminSyncModelsResponses = {
|
||||||
|
/**
|
||||||
|
* Sync result
|
||||||
|
*/
|
||||||
|
200: ApiResponseSyncModelsResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminSyncModelsResponse = AdminSyncModelsResponses[keyof AdminSyncModelsResponses];
|
||||||
|
|
||||||
|
export type AdminCheckAlertsData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/api/admin/alerts/check';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminCheckAlertsErrors = {
|
||||||
|
/**
|
||||||
|
* Invalid or missing admin API key
|
||||||
|
*/
|
||||||
|
401: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminCheckAlertsResponses = {
|
||||||
|
/**
|
||||||
|
* Alert check result
|
||||||
|
*/
|
||||||
|
200: ApiResponseCheckAlertsResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminCheckAlertsResponse = AdminCheckAlertsResponses[keyof AdminCheckAlertsResponses];
|
||||||
|
|
||||||
|
export type AdminWorkspaceAddCreditData = {
|
||||||
|
body: WorkspaceBillingAddCreditParams;
|
||||||
|
path: {
|
||||||
|
/**
|
||||||
|
* Workspace slug
|
||||||
|
*/
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/admin/workspaces/{slug}/add-credit';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminWorkspaceAddCreditErrors = {
|
||||||
|
/**
|
||||||
|
* Invalid amount
|
||||||
|
*/
|
||||||
|
400: unknown;
|
||||||
|
/**
|
||||||
|
* Invalid or missing admin API key
|
||||||
|
*/
|
||||||
|
401: unknown;
|
||||||
|
/**
|
||||||
|
* Workspace not found
|
||||||
|
*/
|
||||||
|
404: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminWorkspaceAddCreditResponses = {
|
||||||
|
/**
|
||||||
|
* Credit added
|
||||||
|
*/
|
||||||
|
200: ApiResponseWorkspaceBillingCurrentResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminWorkspaceAddCreditResponse = AdminWorkspaceAddCreditResponses[keyof AdminWorkspaceAddCreditResponses];
|
||||||
|
|
||||||
export type ModelCapabilityCreateData = {
|
export type ModelCapabilityCreateData = {
|
||||||
body: CreateModelCapabilityRequest;
|
body: CreateModelCapabilityRequest;
|
||||||
path?: never;
|
path?: never;
|
||||||
@ -5917,146 +6155,6 @@ export type ModelPricingListResponses = {
|
|||||||
|
|
||||||
export type ModelPricingListResponse = ModelPricingListResponses[keyof ModelPricingListResponses];
|
export type ModelPricingListResponse = ModelPricingListResponses[keyof ModelPricingListResponses];
|
||||||
|
|
||||||
export type Api2FaDisableData = {
|
|
||||||
body: Disable2FaParams;
|
|
||||||
path?: never;
|
|
||||||
query?: never;
|
|
||||||
url: '/api/auth/2fa/disable';
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Api2FaDisableErrors = {
|
|
||||||
/**
|
|
||||||
* 2FA not enabled or invalid code/password
|
|
||||||
*/
|
|
||||||
400: unknown;
|
|
||||||
/**
|
|
||||||
* Unauthorized
|
|
||||||
*/
|
|
||||||
401: unknown;
|
|
||||||
/**
|
|
||||||
* Not found
|
|
||||||
*/
|
|
||||||
404: ApiResponseApiError;
|
|
||||||
/**
|
|
||||||
* Internal server error
|
|
||||||
*/
|
|
||||||
500: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Api2FaDisableError = Api2FaDisableErrors[keyof Api2FaDisableErrors];
|
|
||||||
|
|
||||||
export type Api2FaDisableResponses = {
|
|
||||||
/**
|
|
||||||
* 2FA disabled
|
|
||||||
*/
|
|
||||||
200: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Api2FaEnableData = {
|
|
||||||
body?: never;
|
|
||||||
path?: never;
|
|
||||||
query?: never;
|
|
||||||
url: '/api/auth/2fa/enable';
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Api2FaEnableErrors = {
|
|
||||||
/**
|
|
||||||
* Unauthorized
|
|
||||||
*/
|
|
||||||
401: unknown;
|
|
||||||
/**
|
|
||||||
* Not found
|
|
||||||
*/
|
|
||||||
404: ApiResponseApiError;
|
|
||||||
/**
|
|
||||||
* 2FA already enabled
|
|
||||||
*/
|
|
||||||
409: unknown;
|
|
||||||
/**
|
|
||||||
* Internal server error
|
|
||||||
*/
|
|
||||||
500: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Api2FaEnableError = Api2FaEnableErrors[keyof Api2FaEnableErrors];
|
|
||||||
|
|
||||||
export type Api2FaEnableResponses = {
|
|
||||||
/**
|
|
||||||
* 2FA setup initiated
|
|
||||||
*/
|
|
||||||
200: Enable2FaResponse;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Api2FaEnableResponse = Api2FaEnableResponses[keyof Api2FaEnableResponses];
|
|
||||||
|
|
||||||
export type Api2FaStatusData = {
|
|
||||||
body?: never;
|
|
||||||
path?: never;
|
|
||||||
query?: never;
|
|
||||||
url: '/api/auth/2fa/status';
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Api2FaStatusErrors = {
|
|
||||||
/**
|
|
||||||
* Unauthorized
|
|
||||||
*/
|
|
||||||
401: unknown;
|
|
||||||
/**
|
|
||||||
* Not found
|
|
||||||
*/
|
|
||||||
404: ApiResponseApiError;
|
|
||||||
/**
|
|
||||||
* Internal server error
|
|
||||||
*/
|
|
||||||
500: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Api2FaStatusError = Api2FaStatusErrors[keyof Api2FaStatusErrors];
|
|
||||||
|
|
||||||
export type Api2FaStatusResponses = {
|
|
||||||
/**
|
|
||||||
* 2FA status
|
|
||||||
*/
|
|
||||||
200: Get2FaStatusResponse;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Api2FaStatusResponse = Api2FaStatusResponses[keyof Api2FaStatusResponses];
|
|
||||||
|
|
||||||
export type Api2FaVerifyData = {
|
|
||||||
body: Verify2FaParams;
|
|
||||||
path?: never;
|
|
||||||
query?: never;
|
|
||||||
url: '/api/auth/2fa/verify';
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Api2FaVerifyErrors = {
|
|
||||||
/**
|
|
||||||
* 2FA not set up
|
|
||||||
*/
|
|
||||||
400: unknown;
|
|
||||||
/**
|
|
||||||
* Unauthorized or invalid code
|
|
||||||
*/
|
|
||||||
401: unknown;
|
|
||||||
/**
|
|
||||||
* Not found
|
|
||||||
*/
|
|
||||||
404: ApiResponseApiError;
|
|
||||||
/**
|
|
||||||
* Internal server error
|
|
||||||
*/
|
|
||||||
500: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Api2FaVerifyError = Api2FaVerifyErrors[keyof Api2FaVerifyErrors];
|
|
||||||
|
|
||||||
export type Api2FaVerifyResponses = {
|
|
||||||
/**
|
|
||||||
* 2FA verified and enabled
|
|
||||||
*/
|
|
||||||
200: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ApiAuthCaptchaData = {
|
export type ApiAuthCaptchaData = {
|
||||||
body: CaptchaQuery;
|
body: CaptchaQuery;
|
||||||
path?: never;
|
path?: never;
|
||||||
@ -6299,39 +6397,6 @@ export type ApiUserChangePasswordResponses = {
|
|||||||
|
|
||||||
export type ApiUserChangePasswordResponse = ApiUserChangePasswordResponses[keyof ApiUserChangePasswordResponses];
|
export type ApiUserChangePasswordResponse = ApiUserChangePasswordResponses[keyof ApiUserChangePasswordResponses];
|
||||||
|
|
||||||
export type ApiUserRequestPasswordResetData = {
|
|
||||||
body: ResetPasswordParams;
|
|
||||||
path?: never;
|
|
||||||
query?: never;
|
|
||||||
url: '/api/auth/password/reset';
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ApiUserRequestPasswordResetErrors = {
|
|
||||||
/**
|
|
||||||
* Unauthorized
|
|
||||||
*/
|
|
||||||
401: ApiResponseApiError;
|
|
||||||
/**
|
|
||||||
* User not found
|
|
||||||
*/
|
|
||||||
404: ApiResponseApiError;
|
|
||||||
/**
|
|
||||||
* Internal server error
|
|
||||||
*/
|
|
||||||
500: ApiResponseApiError;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ApiUserRequestPasswordResetError = ApiUserRequestPasswordResetErrors[keyof ApiUserRequestPasswordResetErrors];
|
|
||||||
|
|
||||||
export type ApiUserRequestPasswordResetResponses = {
|
|
||||||
/**
|
|
||||||
* Password reset email sent
|
|
||||||
*/
|
|
||||||
200: ApiResponseString;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ApiUserRequestPasswordResetResponse = ApiUserRequestPasswordResetResponses[keyof ApiUserRequestPasswordResetResponses];
|
|
||||||
|
|
||||||
export type ApiUserConfirmPasswordResetData = {
|
export type ApiUserConfirmPasswordResetData = {
|
||||||
body: ConfirmResetPasswordParams;
|
body: ConfirmResetPasswordParams;
|
||||||
path?: never;
|
path?: never;
|
||||||
@ -6365,6 +6430,39 @@ export type ApiUserConfirmPasswordResetResponses = {
|
|||||||
|
|
||||||
export type ApiUserConfirmPasswordResetResponse = ApiUserConfirmPasswordResetResponses[keyof ApiUserConfirmPasswordResetResponses];
|
export type ApiUserConfirmPasswordResetResponse = ApiUserConfirmPasswordResetResponses[keyof ApiUserConfirmPasswordResetResponses];
|
||||||
|
|
||||||
|
export type ApiUserRequestPasswordResetData = {
|
||||||
|
body: ResetPasswordParams;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/api/auth/password/reset';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiUserRequestPasswordResetErrors = {
|
||||||
|
/**
|
||||||
|
* Unauthorized
|
||||||
|
*/
|
||||||
|
401: ApiResponseApiError;
|
||||||
|
/**
|
||||||
|
* User not found
|
||||||
|
*/
|
||||||
|
404: ApiResponseApiError;
|
||||||
|
/**
|
||||||
|
* Internal server error
|
||||||
|
*/
|
||||||
|
500: ApiResponseApiError;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiUserRequestPasswordResetError = ApiUserRequestPasswordResetErrors[keyof ApiUserRequestPasswordResetErrors];
|
||||||
|
|
||||||
|
export type ApiUserRequestPasswordResetResponses = {
|
||||||
|
/**
|
||||||
|
* Password reset email sent
|
||||||
|
*/
|
||||||
|
200: ApiResponseString;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiUserRequestPasswordResetResponse = ApiUserRequestPasswordResetResponses[keyof ApiUserRequestPasswordResetResponses];
|
||||||
|
|
||||||
export type ApiAuthRegisterData = {
|
export type ApiAuthRegisterData = {
|
||||||
body: RegisterParams;
|
body: RegisterParams;
|
||||||
path?: never;
|
path?: never;
|
||||||
@ -14803,7 +14901,7 @@ export type GitReadmeData = {
|
|||||||
};
|
};
|
||||||
query?: {
|
query?: {
|
||||||
/**
|
/**
|
||||||
* Git reference (branch, tag, commit). Defaults to HEAD.
|
* Git reference (branch, tag, commit). Defaults to the repository's default branch.
|
||||||
*/
|
*/
|
||||||
ref?: string;
|
ref?: string;
|
||||||
};
|
};
|
||||||
@ -17961,6 +18059,38 @@ export type GetProfileByUsernameResponses = {
|
|||||||
|
|
||||||
export type GetProfileByUsernameResponse = GetProfileByUsernameResponses[keyof GetProfileByUsernameResponses];
|
export type GetProfileByUsernameResponse = GetProfileByUsernameResponses[keyof GetProfileByUsernameResponses];
|
||||||
|
|
||||||
|
export type GetUserActivityData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
query?: {
|
||||||
|
page?: number;
|
||||||
|
per_page?: number;
|
||||||
|
};
|
||||||
|
url: '/api/users/{username}/activity';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetUserActivityErrors = {
|
||||||
|
/**
|
||||||
|
* Unauthorized
|
||||||
|
*/
|
||||||
|
401: unknown;
|
||||||
|
/**
|
||||||
|
* Not found
|
||||||
|
*/
|
||||||
|
404: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetUserActivityResponses = {
|
||||||
|
/**
|
||||||
|
* Get user activity
|
||||||
|
*/
|
||||||
|
200: ApiResponseUserActivityResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetUserActivityResponse = GetUserActivityResponses[keyof GetUserActivityResponses];
|
||||||
|
|
||||||
export type UnsubscribeTargetData = {
|
export type UnsubscribeTargetData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path: {
|
path: {
|
||||||
@ -18094,6 +18224,35 @@ export type GetSubscriberCountResponses = {
|
|||||||
200: unknown;
|
200: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GetFollowingListData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/users/{username}/following';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetFollowingListErrors = {
|
||||||
|
/**
|
||||||
|
* Unauthorized
|
||||||
|
*/
|
||||||
|
401: unknown;
|
||||||
|
/**
|
||||||
|
* Not found
|
||||||
|
*/
|
||||||
|
404: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetFollowingListResponses = {
|
||||||
|
/**
|
||||||
|
* List following users
|
||||||
|
*/
|
||||||
|
200: ApiResponseVecUserCard;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetFollowingListResponse = GetFollowingListResponses[keyof GetFollowingListResponses];
|
||||||
|
|
||||||
export type GetSubscriptionCountData = {
|
export type GetSubscriptionCountData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path: {
|
path: {
|
||||||
@ -18237,6 +18396,35 @@ export type GetUserReposResponses = {
|
|||||||
|
|
||||||
export type GetUserReposResponse = GetUserReposResponses[keyof GetUserReposResponses];
|
export type GetUserReposResponse = GetUserReposResponses[keyof GetUserReposResponses];
|
||||||
|
|
||||||
|
export type GetUserStarsData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/users/{username}/stars';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetUserStarsErrors = {
|
||||||
|
/**
|
||||||
|
* Unauthorized
|
||||||
|
*/
|
||||||
|
401: unknown;
|
||||||
|
/**
|
||||||
|
* Not found
|
||||||
|
*/
|
||||||
|
404: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetUserStarsResponses = {
|
||||||
|
/**
|
||||||
|
* Get user stars
|
||||||
|
*/
|
||||||
|
200: ApiResponseUserStarsResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetUserStarsResponse = GetUserStarsResponses[keyof GetUserStarsResponses];
|
||||||
|
|
||||||
export type WorkspaceCreateData = {
|
export type WorkspaceCreateData = {
|
||||||
body: WorkspaceInitParams;
|
body: WorkspaceInitParams;
|
||||||
path?: never;
|
path?: never;
|
||||||
@ -18295,35 +18483,6 @@ export type WorkspaceAcceptInvitationResponses = {
|
|||||||
|
|
||||||
export type WorkspaceAcceptInvitationResponse = WorkspaceAcceptInvitationResponses[keyof WorkspaceAcceptInvitationResponses];
|
export type WorkspaceAcceptInvitationResponse = WorkspaceAcceptInvitationResponses[keyof WorkspaceAcceptInvitationResponses];
|
||||||
|
|
||||||
export type WorkspaceMyInvitationsData = {
|
|
||||||
body?: never;
|
|
||||||
path?: never;
|
|
||||||
query?: never;
|
|
||||||
url: '/api/workspaces/me/invitations';
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkspaceMyInvitationsErrors = {
|
|
||||||
/**
|
|
||||||
* Unauthorized
|
|
||||||
*/
|
|
||||||
401: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ApiResponseVecMyWorkspaceInvitation = {
|
|
||||||
code: number;
|
|
||||||
message: string;
|
|
||||||
data?: Array<MyWorkspaceInvitation>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkspaceMyInvitationsResponses = {
|
|
||||||
/**
|
|
||||||
* List my workspace invitations
|
|
||||||
*/
|
|
||||||
200: ApiResponseVecMyWorkspaceInvitation;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkspaceMyInvitationsResponse = WorkspaceMyInvitationsResponses[keyof WorkspaceMyInvitationsResponses];
|
|
||||||
|
|
||||||
export type WorkspaceAcceptInvitationBySlugData = {
|
export type WorkspaceAcceptInvitationBySlugData = {
|
||||||
body: WorkspaceAcceptBySlugParams;
|
body: WorkspaceAcceptBySlugParams;
|
||||||
path?: never;
|
path?: never;
|
||||||
@ -18332,6 +18491,10 @@ export type WorkspaceAcceptInvitationBySlugData = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type WorkspaceAcceptInvitationBySlugErrors = {
|
export type WorkspaceAcceptInvitationBySlugErrors = {
|
||||||
|
/**
|
||||||
|
* Invalid or expired token
|
||||||
|
*/
|
||||||
|
400: unknown;
|
||||||
/**
|
/**
|
||||||
* Unauthorized
|
* Unauthorized
|
||||||
*/
|
*/
|
||||||
@ -18378,6 +18541,29 @@ export type WorkspaceListResponses = {
|
|||||||
|
|
||||||
export type WorkspaceListResponse2 = WorkspaceListResponses[keyof WorkspaceListResponses];
|
export type WorkspaceListResponse2 = WorkspaceListResponses[keyof WorkspaceListResponses];
|
||||||
|
|
||||||
|
export type WorkspaceMyInvitationsData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/api/workspaces/me/invitations';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceMyInvitationsErrors = {
|
||||||
|
/**
|
||||||
|
* Unauthorized
|
||||||
|
*/
|
||||||
|
401: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceMyInvitationsResponses = {
|
||||||
|
/**
|
||||||
|
* List my workspace invitations
|
||||||
|
*/
|
||||||
|
200: ApiResponseVecMyWorkspaceInvitation;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceMyInvitationsResponse = WorkspaceMyInvitationsResponses[keyof WorkspaceMyInvitationsResponses];
|
||||||
|
|
||||||
export type WorkspaceDeleteData = {
|
export type WorkspaceDeleteData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path: {
|
path: {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user