Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/agentsight/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ OpenAI Responses API 的 `response.completed` 事件可能跨多个 TLS record
| `agentsight discover` | `src/bin/cli/discover.rs` | 发现运行中的 AI Agent |
| `agentsight metrics` | `src/bin/cli/metrics.rs` | Prometheus 格式指标 |
| `agentsight interruption` | `src/bin/cli/interruption.rs` | 查询/管理会话中断事件 |
| `agentsight dashboard` | `src/bin/cli/dashboard.rs` | 查看 Dashboard 认证状态与 Token |

### 6.1 Interruption CLI 详细用法

Expand Down Expand Up @@ -217,6 +218,9 @@ agentsight interruption --db /path/to/interruption_events.db list --last 48
| `/api/interruptions/{id}/resolve` | POST | 标记中断为已解决 |
| `/api/sessions/{id}/interruptions` | GET | 指定 session 的所有中断 |
| `/api/conversations/{id}/interruptions` | GET | 指定 conversation 的所有中断 |
| `/api/auth/login` | POST | Dashboard 登录(Body: `{"token":"..."}` ),成功设置 httpOnly cookie |
| `/api/auth/status` | GET | 返回 `{"auth_enabled": bool}`(免认证,供前端判断是否需登录)|
| `/api/auth/verify` | GET | 校验当前 session cookie/token 是否有效,返回 `{"authenticated": bool}` |

## 9. Frontend

Expand Down
5 changes: 5 additions & 0 deletions src/agentsight/agentsight.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
"runtime": {
"sls_logtail_path": ""
},
"server": {
"auth": {
"enabled": true
}
},
"deadloop": {
"enabled": false,
"kill_after_count": 3
Expand Down
123 changes: 106 additions & 17 deletions src/agentsight/dashboard/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,120 @@
import React from 'react';
import { HashRouter, Routes, Route } from 'react-router-dom';
import React, { useEffect, useState } from 'react';
import { HashRouter, Routes, Route, useNavigate, useLocation } from 'react-router-dom';
import { NavBar } from './components/NavBar';
import { AgentHealthSidebar } from './components/AgentHealthSidebar';
import { ConversationList } from './pages/ConversationList';
import { AtifViewerPage } from './pages/AtifViewerPage';
import { TokenSavingsPage } from './pages/TokenSavingsPage';
import { SkillMetricsPage } from './pages/SkillMetricsPage';
import { SecurityObservabilityPage } from './pages/SecurityObservabilityPage';
import { LoginPage } from './pages/LoginPage';
import { fetchAuthStatus, fetchAuthVerify, login } from './utils/apiClient';

/** Auth gate: checks auth status and renders LoginPage when needed. */
const AuthGate: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [authState, setAuthState] = useState<'loading' | 'authenticated' | 'unauthenticated' | 'disabled'>('loading');
const navigate = useNavigate();
const location = useLocation();

useEffect(() => {
// Check for token in URL query parameter (e.g. ?token=xxx from CLI link)
const urlParams = new URLSearchParams(window.location.search);
const urlToken = urlParams.get('token');
if (urlToken) {
// Auto-login with the token from URL, then clean the URL
(async () => {
try {
const ok = await login(urlToken);
// Remove token from URL regardless of success
urlParams.delete('token');
const cleanSearch = urlParams.toString();
const cleanUrl = window.location.pathname
+ (cleanSearch ? '?' + cleanSearch : '')
+ window.location.hash;
window.history.replaceState(null, '', cleanUrl);
if (ok) {
setAuthState('authenticated');
} else {
setAuthState('unauthenticated');
}
} catch {
setAuthState('unauthenticated');
}
})();
return;
}

// Always allow the login page itself
if (location.pathname === '/login') {
setAuthState('unauthenticated');
return;
}

(async () => {
try {
const status = await fetchAuthStatus();
if (!status.auth_enabled) {
setAuthState('disabled');
return;
}
const verify = await fetchAuthVerify();
if (verify.authenticated) {
setAuthState('authenticated');
} else {
setAuthState('unauthenticated');
}
} catch {
// Server unreachable — skip auth check
setAuthState('disabled');
}
})();
}, [location.pathname]);

const handleAuthenticated = () => {
setAuthState('authenticated');
navigate('/');
};

if (authState === 'loading') {
return (
<div className="min-h-screen flex items-center justify-center text-gray-400">
Loading...
</div>
);
}

if (authState === 'unauthenticated') {
return <LoginPage onAuthenticated={handleAuthenticated} />;
}

return <>{children}</>;
};

const App: React.FC = () => {
return (
<HashRouter>
<div className="min-h-screen bg-gray-50 flex flex-col">
<NavBar />
<div className="flex flex-1 overflow-hidden">
<main className="flex-1 overflow-auto">
<Routes>
<Route path="/" element={<ConversationList />} />
<Route path="/savings" element={<TokenSavingsPage />} />
<Route path="/skills" element={<SkillMetricsPage />} />
<Route path="/security" element={<SecurityObservabilityPage />} />
<Route path="/atif" element={<AtifViewerPage />} />
</Routes>
</main>
<AgentHealthSidebar />
</div>
</div>
<Routes>
<Route path="/login" element={<LoginPage onAuthenticated={() => { window.location.hash = '#/'; window.location.reload(); }} />} />
<Route path="/*" element={
<AuthGate>
<div className="min-h-screen bg-gray-50 flex flex-col">
<NavBar />
<div className="flex flex-1 overflow-hidden">
<main className="flex-1 overflow-auto">
<Routes>
<Route path="/" element={<ConversationList />} />
<Route path="/savings" element={<TokenSavingsPage />} />
<Route path="/skills" element={<SkillMetricsPage />} />
<Route path="/security" element={<SecurityObservabilityPage />} />
<Route path="/atif" element={<AtifViewerPage />} />
</Routes>
</main>
<AgentHealthSidebar />
</div>
</div>
</AuthGate>
} />
</Routes>
</HashRouter>
);
};
Expand Down
86 changes: 86 additions & 0 deletions src/agentsight/dashboard/src/pages/LoginPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import React, { useState } from 'react';
import { login } from '../utils/apiClient';

interface LoginPageProps {
onAuthenticated: () => void;
}

export const LoginPage: React.FC<LoginPageProps> = ({ onAuthenticated }) => {
const [token, setToken] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!token.trim()) {
setError('Please enter a token');
return;
}
setLoading(true);
setError('');
try {
const ok = await login(token.trim());
if (ok) {
onAuthenticated();
} else {
setError('Invalid token. Check the token with `agentsight dashboard`.');
}
} catch (err) {
setError('Connection error. Is the AgentSight server running?');
} finally {
setLoading(false);
}
};

return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="bg-white p-8 rounded-lg shadow-md w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-gray-900">AgentSight</h1>
<p className="text-gray-500 mt-2">Enter your dashboard token to continue</p>
</div>

<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="token"
className="block text-sm font-medium text-gray-700 mb-1"
>
Dashboard Token
</label>
<input
id="token"
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="Paste your token here"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
autoFocus
/>
</div>

{error && (
<div className="text-red-600 text-sm bg-red-50 p-2 rounded">
{error}
</div>
)}

<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Verifying...' : 'Sign In'}
</button>
</form>

<div className="mt-6 text-xs text-gray-400 text-center">
<p>Run <code className="bg-gray-100 px-1 rounded">agentsight dashboard</code> to view your token.</p>
<p className="mt-1">
Or use <code className="bg-gray-100 px-1 rounded">--full-token</code> to show the complete value.
</p>
</div>
</div>
</div>
);
};
60 changes: 55 additions & 5 deletions src/agentsight/dashboard/src/utils/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,12 @@ export interface TraceEventDetail {
// ─── Internal helpers ────────────────────────────────────────────────────────

async function apiFetch<T>(url: string): Promise<T> {
const res = await fetch(url);
const res = await fetch(url, { credentials: 'same-origin' });
if (res.status === 401) {
// Session expired or invalid — redirect to login
window.location.hash = '#/login';
throw new Error('Authentication required');
}
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
throw new Error(`API ${url} -> ${res.status}: ${text}`);
Expand Down Expand Up @@ -477,7 +482,7 @@ export async function fetchInterruptionCount(
export async function resolveInterruption(interruptionId: string): Promise<void> {
const res = await fetch(
`${API_BASE}/api/interruptions/${encodeURIComponent(interruptionId)}/resolve`,
{ method: 'POST' }
{ method: 'POST', credentials: 'same-origin' }
);
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
Expand Down Expand Up @@ -529,7 +534,7 @@ export async function fetchAgentHealth(opts?: { includeClients?: boolean }): Pro
* Acknowledge and remove an offline agent by PID.
*/
export async function deleteAgentHealth(pid: number): Promise<void> {
const res = await fetch(`${API_BASE}/api/agent-health/${pid}`, { method: 'DELETE' });
const res = await fetch(`${API_BASE}/api/agent-health/${pid}`, { method: 'DELETE', credentials: 'same-origin' });
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
throw new Error(`DELETE /api/agent-health/${pid} -> ${res.status}: ${text}`);
Expand All @@ -541,7 +546,7 @@ export async function deleteAgentHealth(pid: number): Promise<void> {
* Returns the new PID on success.
*/
export async function restartAgentHealth(pid: number): Promise<{ ok: boolean; new_pid: number; cmd: string[] }> {
const res = await fetch(`${API_BASE}/api/agent-health/${pid}/restart`, { method: 'POST' });
const res = await fetch(`${API_BASE}/api/agent-health/${pid}/restart`, { method: 'POST', credentials: 'same-origin' });
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(`POST /api/agent-health/${pid}/restart -> ${res.status}: ${body.error ?? res.statusText}`);
Expand Down Expand Up @@ -819,7 +824,11 @@ function buildQuery(params?: object): string {
}

async function securityFetch<T>(url: string): Promise<SecurityApiResponse<T>> {
const res = await fetch(url);
const res = await fetch(url, { credentials: 'same-origin' });
if (res.status === 401) {
window.location.hash = '#/login';
throw new Error('Authentication required');
}
const text = await res.text().catch(() => '');
let body: unknown = null;
if (text) {
Expand Down Expand Up @@ -1015,3 +1024,44 @@ export async function fetchSkillMetrics(
`${API_BASE}/api/skill-metrics${buildSkillMetricsParams(startNs, endNs, agentName, granularity)}`
);
}

// ─── Authentication API ──────────────────────────────────────────────────────

export interface AuthStatusResponse {
auth_enabled: boolean;
}

export interface AuthVerifyResponse {
authenticated: boolean;
}

/**
* Check whether dashboard authentication is enabled.
*/
export async function fetchAuthStatus(): Promise<AuthStatusResponse> {
const res = await fetch(`${API_BASE}/api/auth/status`);
if (!res.ok) throw new Error(`GET /api/auth/status -> ${res.status}`);
return res.json();
}

/**
* Verify the current session (cookie-based).
*/
export async function fetchAuthVerify(): Promise<AuthVerifyResponse> {
const res = await fetch(`${API_BASE}/api/auth/verify`, { credentials: 'same-origin' });
if (!res.ok) throw new Error(`GET /api/auth/verify -> ${res.status}`);
return res.json();
}

/**
* Log in with a token string. Sets a session cookie on success.
*/
export async function login(token: string): Promise<boolean> {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ token }),
});
return res.ok;
}
7 changes: 7 additions & 0 deletions src/agentsight/src/bin/agentsight.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ use structopt::StructOpt;

mod cli;
#[cfg(feature = "server")]
use cli::dashboard::DashboardCommand;
#[cfg(feature = "server")]
use cli::serve::ServeCommand;
use cli::{
audit::AuditCommand, discover::DiscoverCommand, interruption::InterruptionCommand,
Expand Down Expand Up @@ -43,6 +45,9 @@ pub enum Command {
/// Start the API server
#[cfg(feature = "server")]
Serve(ServeCommand),
/// Display dashboard authentication status and token
#[cfg(feature = "server")]
Dashboard(DashboardCommand),
}

fn main() {
Expand All @@ -59,5 +64,7 @@ fn main() {
Command::Summary(summary_cmd) => summary_cmd.execute(),
#[cfg(feature = "server")]
Command::Serve(serve_cmd) => serve_cmd.execute(),
#[cfg(feature = "server")]
Command::Dashboard(dashboard_cmd) => dashboard_cmd.execute(),
}
}
Loading
Loading