mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
[HDX-3976] CLI: migrate from apiUrl to appUrl with interactive login flow (#2101)
## Summary Migrates the CLI from using API URLs (`-s, --server`) to app URLs (`-a, --app-url`), and adds interactive login prompts on expired/missing sessions. Linear: https://linear.app/clickhouse/issue/HDX-3976 --- ## Breaking Change **The `-s` / `--server` flag has been removed.** All commands (except `upload-sourcemaps`) now use `-a` / `--app-url` instead. The URL semantics have changed: users should now provide the **HyperDX app URL** (e.g. `http://localhost:8080`), not the API URL. The CLI derives the API URL internally by appending `/api`. | Before | After | |---|---| | `hdx auth login -s http://localhost:8080/api` | `hdx auth login -a http://localhost:8080` | | `hdx tui -s http://localhost:8080/api` | `hdx tui -a http://localhost:8080` | | `hdx sources -s http://localhost:8080/api` | `hdx sources -a http://localhost:8080` | > **Note:** `upload-sourcemaps` is unchanged — it still uses `--apiUrl` / `-u` as before. **Existing saved sessions are auto-migrated** — old `session.json` files with `apiUrl` are converted to `appUrl` on first load. --- ## Changes ### `apiUrl` → `appUrl` migration - `ApiClient` now accepts `appUrl` and derives `apiUrl` by appending `/api` - `SessionConfig` stores `appUrl`; legacy sessions with `apiUrl` auto-migrate on load - All commands use `-a, --app-url` instead of `-s, --server` ### Interactive login flow (HDX-3976) - `hdx auth login` no longer requires `-a` — it prompts interactively for login method, app URL, then credentials - Login method selector is extensible (currently Email/Password, designed for future OAuth support) - **Expired sessions now prompt for re-login** instead of printing an error and exiting - The app URL field is autofilled with the last used value so users can just hit Enter - No longer requires manual deletion of `~/.config/hyperdx/cli/session.json` to recover from expired sessions - Non-TUI commands (`sources`, `dashboards`, `query`) also launch interactive login on expired/missing sessions via `ensureSession()` helper ### TUI (`App.tsx`) - Detects expired session on mount and shows "Session expired" message with editable URL field - If the user changes the URL during re-login, the client is recreated - 401/403 errors during data loading bounce back to the login screen instead of showing raw error messages ### Input validation & error handling - App URL inputs are validated — rejects non-`http://` or `https://` URLs with a clear inline error - `ApiClient.login()` catches network/URL errors and returns `false` instead of crashing - `ApiClient.login()` verifies the session after a 302/200 response by calling `checkSession()` — prevents false "Logged in" messages from servers that return 302 without a valid session (e.g. SSO redirects) - Login failure messages now mention both credentials and server URL --- ## Files changed - `packages/cli/src/api/client.ts` — accepts `appUrl`, derives `apiUrl`, exposes `getAppUrl()`, login validation - `packages/cli/src/utils/config.ts` — `SessionConfig.appUrl`, backward-compat migration - `packages/cli/src/cli.tsx` — `-a` flag, `LoginPrompt`, `ReLoginPrompt`, `ensureSession()`, URL validation - `packages/cli/src/App.tsx` — expired session detection, editable URL on re-login, 401/403 handling - `packages/cli/src/components/LoginForm.tsx` — app URL prompt field, `message` prop, URL validation
This commit is contained in:
parent
7a9882d421
commit
4dea3621a7
6 changed files with 483 additions and 117 deletions
10
.changeset/cli-app-url-migration.md
Normal file
10
.changeset/cli-app-url-migration.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
"@hyperdx/cli": minor
|
||||
---
|
||||
|
||||
**Breaking:** Replace `-s`/`--server` flag with `-a`/`--app-url` across all CLI commands (except `upload-sourcemaps`). Users should now provide the HyperDX app URL instead of the API URL — the CLI derives the API URL by appending `/api`.
|
||||
|
||||
- `hdx auth login` now prompts interactively for login method, app URL, and credentials (no flags required)
|
||||
- Expired/missing sessions prompt for re-login with the last URL autofilled instead of printing an error
|
||||
- Add URL input validation and post-login session verification
|
||||
- Existing saved sessions are auto-migrated from `apiUrl` to `appUrl`
|
||||
|
|
@ -18,7 +18,7 @@ import EventViewer from '@/components/EventViewer';
|
|||
type Screen = 'loading' | 'login' | 'pick-source' | 'events' | 'alerts';
|
||||
|
||||
interface AppProps {
|
||||
apiUrl: string;
|
||||
appUrl: string;
|
||||
/** Pre-set search query from CLI flags */
|
||||
query?: string;
|
||||
/** Pre-set source name from CLI flags */
|
||||
|
|
@ -27,9 +27,11 @@ interface AppProps {
|
|||
follow?: boolean;
|
||||
}
|
||||
|
||||
export default function App({ apiUrl, query, sourceName, follow }: AppProps) {
|
||||
export default function App({ appUrl, query, sourceName, follow }: AppProps) {
|
||||
const [screen, setScreen] = useState<Screen>('loading');
|
||||
const [client] = useState(() => new ApiClient({ apiUrl }));
|
||||
const [client, setClient] = useState(() => new ApiClient({ appUrl }));
|
||||
const [currentAppUrl, setCurrentAppUrl] = useState(appUrl);
|
||||
const [sessionExpired, setSessionExpired] = useState(false);
|
||||
const [eventSources, setLogSources] = useState<SourceResponse[]>([]);
|
||||
const [savedSearches, setSavedSearches] = useState<SavedSearchResponse[]>([]);
|
||||
const [selectedSource, setSelectedSource] = useState<SourceResponse | null>(
|
||||
|
|
@ -43,18 +45,19 @@ export default function App({ apiUrl, query, sourceName, follow }: AppProps) {
|
|||
(async () => {
|
||||
const valid = await client.checkSession();
|
||||
if (valid) {
|
||||
await loadData();
|
||||
await loadData(client);
|
||||
} else {
|
||||
setSessionExpired(true);
|
||||
setScreen('login');
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
const loadData = async (apiClient: ApiClient) => {
|
||||
try {
|
||||
const [sources, searches] = await Promise.all([
|
||||
client.getSources(),
|
||||
client.getSavedSearches().catch(() => [] as SavedSearchResponse[]),
|
||||
apiClient.getSources(),
|
||||
apiClient.getSavedSearches().catch(() => [] as SavedSearchResponse[]),
|
||||
]);
|
||||
|
||||
const queryableSources = sources.filter(
|
||||
|
|
@ -92,14 +95,34 @@ export default function App({ apiUrl, query, sourceName, follow }: AppProps) {
|
|||
|
||||
setScreen('pick-source');
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
// Treat auth errors as session issues — bounce back to login
|
||||
if (msg.includes('401') || msg.includes('403')) {
|
||||
setSessionExpired(true);
|
||||
setScreen('login');
|
||||
return;
|
||||
}
|
||||
setError(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogin = async (email: string, password: string) => {
|
||||
const ok = await client.login(email, password);
|
||||
const handleLogin = async (
|
||||
loginAppUrl: string,
|
||||
email: string,
|
||||
password: string,
|
||||
) => {
|
||||
// Recreate client if the user changed the URL
|
||||
let activeClient = client;
|
||||
if (loginAppUrl !== currentAppUrl) {
|
||||
activeClient = new ApiClient({ appUrl: loginAppUrl });
|
||||
setClient(activeClient);
|
||||
setCurrentAppUrl(loginAppUrl);
|
||||
}
|
||||
|
||||
const ok = await activeClient.login(email, password);
|
||||
if (ok) {
|
||||
await loadData();
|
||||
setSessionExpired(false);
|
||||
await loadData(activeClient);
|
||||
}
|
||||
return ok;
|
||||
};
|
||||
|
|
@ -148,13 +171,23 @@ export default function App({ apiUrl, query, sourceName, follow }: AppProps) {
|
|||
return (
|
||||
<Box paddingX={1}>
|
||||
<Text>
|
||||
<Spinner type="dots" /> Connecting to {apiUrl}…
|
||||
<Spinner type="dots" /> Connecting to {currentAppUrl}…
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'login':
|
||||
return <LoginForm apiUrl={apiUrl} onLogin={handleLogin} />;
|
||||
return (
|
||||
<LoginForm
|
||||
defaultAppUrl={currentAppUrl}
|
||||
onLogin={handleLogin}
|
||||
message={
|
||||
sessionExpired
|
||||
? 'Session expired — please log in again.'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'pick-source':
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -31,22 +31,28 @@ import { loadSession, saveSession, clearSession } from '@/utils/config';
|
|||
// ------------------------------------------------------------------
|
||||
|
||||
interface ApiClientOptions {
|
||||
apiUrl: string;
|
||||
appUrl: string;
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
private appUrl: string;
|
||||
private apiUrl: string;
|
||||
private cookies: string[] = [];
|
||||
|
||||
constructor(opts: ApiClientOptions) {
|
||||
this.apiUrl = opts.apiUrl.replace(/\/+$/, '');
|
||||
this.appUrl = opts.appUrl.replace(/\/+$/, '');
|
||||
this.apiUrl = `${this.appUrl}/api`;
|
||||
|
||||
const saved = loadSession();
|
||||
if (saved && saved.apiUrl === this.apiUrl) {
|
||||
if (saved && saved.appUrl === this.appUrl) {
|
||||
this.cookies = saved.cookies;
|
||||
}
|
||||
}
|
||||
|
||||
getAppUrl(): string {
|
||||
return this.appUrl;
|
||||
}
|
||||
|
||||
getApiUrl(): string {
|
||||
return this.apiUrl;
|
||||
}
|
||||
|
|
@ -58,20 +64,31 @@ export class ApiClient {
|
|||
// ---- Auth --------------------------------------------------------
|
||||
|
||||
async login(email: string, password: string): Promise<boolean> {
|
||||
const res = await fetch(`${this.apiUrl}/login/password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
redirect: 'manual',
|
||||
});
|
||||
try {
|
||||
const res = await fetch(`${this.apiUrl}/login/password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
||||
if (res.status === 302 || res.status === 200) {
|
||||
this.extractCookies(res);
|
||||
saveSession({ apiUrl: this.apiUrl, cookies: this.cookies });
|
||||
return true;
|
||||
if (res.status === 302 || res.status === 200) {
|
||||
this.extractCookies(res);
|
||||
|
||||
// Verify the session is actually valid — some servers return
|
||||
// 302/200 without setting a real session (e.g. SSO redirects).
|
||||
if (!(await this.checkSession())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
saveSession({ appUrl: this.appUrl, cookies: this.cookies });
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async checkSession(): Promise<boolean> {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
import { _origError } from '@/utils/silenceLogs';
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { render, Box, Text, useApp } from 'ink';
|
||||
import { render, Box, Text, useApp, useInput } from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
import Spinner from 'ink-spinner';
|
||||
import { Command } from 'commander';
|
||||
|
|
@ -19,27 +19,83 @@ import { uploadSourcemaps } from '@/sourcemaps';
|
|||
|
||||
// ---- Standalone interactive login for `hdx auth login` -------------
|
||||
|
||||
/** Returns true if the string is a valid HTTP(S) URL. */
|
||||
function isValidUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Add new login methods here to extend the login flow.
|
||||
const LOGIN_METHODS = [
|
||||
{ id: 'password', label: 'Email / Password' },
|
||||
// { id: 'oauth', label: 'OAuth / SSO' },
|
||||
] as const;
|
||||
|
||||
type LoginMethod = (typeof LOGIN_METHODS)[number]['id'];
|
||||
|
||||
function LoginPrompt({
|
||||
apiUrl,
|
||||
client,
|
||||
initialAppUrl,
|
||||
initialClient,
|
||||
}: {
|
||||
apiUrl: string;
|
||||
client: ApiClient;
|
||||
initialAppUrl?: string;
|
||||
initialClient?: ApiClient;
|
||||
}) {
|
||||
const { exit } = useApp();
|
||||
const [field, setField] = useState<'email' | 'password'>('email');
|
||||
const [field, setField] = useState<
|
||||
'method' | 'appUrl' | 'email' | 'password'
|
||||
>('method');
|
||||
const [methodIdx, setMethodIdx] = useState(0);
|
||||
const [_method, setMethod] = useState<LoginMethod | null>(null);
|
||||
const [appUrl, setAppUrl] = useState(initialAppUrl ?? '');
|
||||
const [client, setClient] = useState<ApiClient | null>(initialClient ?? null);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Arrow-key navigation for login method picker
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (field !== 'method') return;
|
||||
if (key.upArrow || input === 'k') {
|
||||
setMethodIdx(i => Math.max(0, i - 1));
|
||||
}
|
||||
if (key.downArrow || input === 'j') {
|
||||
setMethodIdx(i => Math.min(LOGIN_METHODS.length - 1, i + 1));
|
||||
}
|
||||
if (key.return) {
|
||||
const selected = LOGIN_METHODS[methodIdx];
|
||||
setMethod(selected.id);
|
||||
setField(initialAppUrl ? 'email' : 'appUrl');
|
||||
}
|
||||
},
|
||||
{ isActive: field === 'method' },
|
||||
);
|
||||
|
||||
const handleSubmitAppUrl = useCallback(() => {
|
||||
const trimmed = appUrl.trim();
|
||||
if (!trimmed) return;
|
||||
if (!isValidUrl(trimmed)) {
|
||||
setError('Invalid URL. Please enter a valid http:// or https:// URL.');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
const c = new ApiClient({ appUrl: trimmed });
|
||||
setClient(c);
|
||||
setField('email');
|
||||
}, [appUrl]);
|
||||
|
||||
const handleSubmitEmail = useCallback(() => {
|
||||
if (!email.trim()) return;
|
||||
setField('password');
|
||||
}, [email]);
|
||||
|
||||
const handleSubmitPassword = useCallback(async () => {
|
||||
if (!password) return;
|
||||
if (!password || !client) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const ok = await client.login(email, password);
|
||||
|
|
@ -49,23 +105,25 @@ function LoginPrompt({
|
|||
// Small delay to let Ink unmount before writing to stdout
|
||||
setTimeout(() => {
|
||||
process.stdout.write(
|
||||
chalk.green(`\nLogged in as ${email} (${apiUrl})\n`),
|
||||
chalk.green(`\nLogged in as ${email} (${appUrl})\n`),
|
||||
);
|
||||
}, 50);
|
||||
} else {
|
||||
setError('Login failed. Check your email and password.');
|
||||
setError('Login failed. Check your credentials and server URL.');
|
||||
setField('email');
|
||||
setEmail('');
|
||||
setPassword('');
|
||||
}
|
||||
}, [email, password, client, apiUrl, exit]);
|
||||
}, [email, password, client, appUrl, exit]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text bold color="cyan">
|
||||
HyperDX — Login
|
||||
</Text>
|
||||
<Text dimColor>Server: {apiUrl}</Text>
|
||||
{field !== 'method' && field !== 'appUrl' && (
|
||||
<Text dimColor>Server: {appUrl}</Text>
|
||||
)}
|
||||
<Text> </Text>
|
||||
|
||||
{error && <Text color="red">{error}</Text>}
|
||||
|
|
@ -74,6 +132,29 @@ function LoginPrompt({
|
|||
<Text>
|
||||
<Spinner type="dots" /> Logging in…
|
||||
</Text>
|
||||
) : field === 'method' ? (
|
||||
<Box flexDirection="column">
|
||||
<Text>Login method:</Text>
|
||||
<Text> </Text>
|
||||
{LOGIN_METHODS.map((m, i) => (
|
||||
<Text key={m.id} color={i === methodIdx ? 'green' : undefined}>
|
||||
{i === methodIdx ? '▸ ' : ' '}
|
||||
{m.label}
|
||||
</Text>
|
||||
))}
|
||||
<Text> </Text>
|
||||
<Text dimColor>↑/↓ to navigate, Enter to select</Text>
|
||||
</Box>
|
||||
) : field === 'appUrl' ? (
|
||||
<Box>
|
||||
<Text>HyperDX URL: </Text>
|
||||
<TextInput
|
||||
value={appUrl}
|
||||
onChange={setAppUrl}
|
||||
onSubmit={handleSubmitAppUrl}
|
||||
placeholder="http://localhost:8080"
|
||||
/>
|
||||
</Box>
|
||||
) : field === 'email' ? (
|
||||
<Box>
|
||||
<Text>Email: </Text>
|
||||
|
|
@ -99,19 +180,196 @@ function LoginPrompt({
|
|||
}
|
||||
|
||||
/**
|
||||
* Resolve the server URL: use the provided flag, or fall back to the
|
||||
* saved session's apiUrl. Exits with an error if neither is available.
|
||||
* Resolve the app URL: use the provided flag, or fall back to the
|
||||
* saved session's appUrl. Returns undefined if neither is available.
|
||||
*/
|
||||
function resolveServer(flagValue: string | undefined): string {
|
||||
function resolveServer(flagValue: string | undefined): string | undefined {
|
||||
if (flagValue) return flagValue;
|
||||
const session = loadSession();
|
||||
if (session?.apiUrl) return session.apiUrl;
|
||||
_origError(
|
||||
chalk.red(
|
||||
`No server specified. Use ${chalk.bold('-s <url>')} or run ${chalk.bold('hdx auth login -s <url>')} first.\n`,
|
||||
),
|
||||
if (session?.appUrl) return session.appUrl;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the user has a valid session. If the session is expired or
|
||||
* missing, launches the interactive LoginPrompt (with the last URL
|
||||
* autofilled) and waits for re-authentication.
|
||||
*
|
||||
* Returns an authenticated ApiClient ready for use.
|
||||
*/
|
||||
async function ensureSession(
|
||||
flagAppUrl: string | undefined,
|
||||
): Promise<ApiClient> {
|
||||
const appUrl = resolveServer(flagAppUrl);
|
||||
|
||||
// If we have an appUrl, try the existing session first
|
||||
if (appUrl) {
|
||||
const client = new ApiClient({ appUrl });
|
||||
if (await client.checkSession()) {
|
||||
return client;
|
||||
}
|
||||
// Session expired — show message and re-login
|
||||
process.stderr.write(
|
||||
chalk.yellow('Session expired — launching login…\n\n'),
|
||||
);
|
||||
}
|
||||
|
||||
// Launch interactive login prompt (appUrl autofilled if available)
|
||||
return new Promise<ApiClient>(resolve => {
|
||||
const { waitUntilExit } = render(
|
||||
<ReLoginPrompt defaultAppUrl={appUrl} onAuthenticated={resolve} />,
|
||||
);
|
||||
// If the user ctrl-c's out of the prompt, exit the process
|
||||
waitUntilExit().then(() => {
|
||||
// If the promise was already resolved, this is a no-op.
|
||||
// Otherwise the user quit without logging in.
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight wrapper around LoginPrompt for non-TUI commands.
|
||||
* Resolves the onAuthenticated callback with a ready ApiClient.
|
||||
*/
|
||||
function ReLoginPrompt({
|
||||
defaultAppUrl,
|
||||
onAuthenticated,
|
||||
}: {
|
||||
defaultAppUrl?: string;
|
||||
onAuthenticated: (client: ApiClient) => void;
|
||||
}) {
|
||||
const { exit } = useApp();
|
||||
const [field, setField] = useState<
|
||||
'method' | 'appUrl' | 'email' | 'password'
|
||||
>('method');
|
||||
const [methodIdx, setMethodIdx] = useState(0);
|
||||
const [_method, setMethod] = useState<LoginMethod | null>(null);
|
||||
const [appUrl, setAppUrl] = useState(defaultAppUrl ?? '');
|
||||
const [client, setClient] = useState<ApiClient | null>(null);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (field !== 'method') return;
|
||||
if (key.upArrow || input === 'k') {
|
||||
setMethodIdx(i => Math.max(0, i - 1));
|
||||
}
|
||||
if (key.downArrow || input === 'j') {
|
||||
setMethodIdx(i => Math.min(LOGIN_METHODS.length - 1, i + 1));
|
||||
}
|
||||
if (key.return) {
|
||||
const selected = LOGIN_METHODS[methodIdx];
|
||||
setMethod(selected.id);
|
||||
setField('appUrl');
|
||||
}
|
||||
},
|
||||
{ isActive: field === 'method' },
|
||||
);
|
||||
|
||||
const handleSubmitAppUrl = useCallback(() => {
|
||||
const trimmed = appUrl.trim();
|
||||
if (!trimmed) return;
|
||||
if (!isValidUrl(trimmed)) {
|
||||
setError('Invalid URL. Please enter a valid http:// or https:// URL.');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
const c = new ApiClient({ appUrl: trimmed });
|
||||
setClient(c);
|
||||
setField('email');
|
||||
}, [appUrl]);
|
||||
|
||||
const handleSubmitEmail = useCallback(() => {
|
||||
if (!email.trim()) return;
|
||||
setField('password');
|
||||
}, [email]);
|
||||
|
||||
const handleSubmitPassword = useCallback(async () => {
|
||||
if (!password || !client) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const ok = await client.login(email, password);
|
||||
setLoading(false);
|
||||
if (ok) {
|
||||
exit();
|
||||
setTimeout(() => {
|
||||
process.stdout.write(
|
||||
chalk.green(`Logged in as ${email} (${appUrl})\n\n`),
|
||||
);
|
||||
onAuthenticated(client);
|
||||
}, 50);
|
||||
} else {
|
||||
setError('Login failed. Check your credentials and server URL.');
|
||||
setField('email');
|
||||
setEmail('');
|
||||
setPassword('');
|
||||
}
|
||||
}, [email, password, client, appUrl, exit, onAuthenticated]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text bold color="cyan">
|
||||
HyperDX — Login
|
||||
</Text>
|
||||
{field !== 'method' && field !== 'appUrl' && (
|
||||
<Text dimColor>Server: {appUrl}</Text>
|
||||
)}
|
||||
<Text> </Text>
|
||||
|
||||
{error && <Text color="red">{error}</Text>}
|
||||
|
||||
{loading ? (
|
||||
<Text>
|
||||
<Spinner type="dots" /> Logging in…
|
||||
</Text>
|
||||
) : field === 'method' ? (
|
||||
<Box flexDirection="column">
|
||||
<Text>Login method:</Text>
|
||||
<Text> </Text>
|
||||
{LOGIN_METHODS.map((m, i) => (
|
||||
<Text key={m.id} color={i === methodIdx ? 'green' : undefined}>
|
||||
{i === methodIdx ? '▸ ' : ' '}
|
||||
{m.label}
|
||||
</Text>
|
||||
))}
|
||||
<Text> </Text>
|
||||
<Text dimColor>↑/↓ to navigate, Enter to select</Text>
|
||||
</Box>
|
||||
) : field === 'appUrl' ? (
|
||||
<Box>
|
||||
<Text>HyperDX URL: </Text>
|
||||
<TextInput
|
||||
value={appUrl}
|
||||
onChange={setAppUrl}
|
||||
onSubmit={handleSubmitAppUrl}
|
||||
placeholder="http://localhost:8080"
|
||||
/>
|
||||
</Box>
|
||||
) : field === 'email' ? (
|
||||
<Box>
|
||||
<Text>Email: </Text>
|
||||
<TextInput
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
onSubmit={handleSubmitEmail}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
<Text>Password: </Text>
|
||||
<TextInput
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
onSubmit={handleSubmitPassword}
|
||||
mask="*"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const program = new Command();
|
||||
|
|
@ -127,20 +385,33 @@ program
|
|||
program
|
||||
.command('tui')
|
||||
.description('Interactive TUI for event search and tail')
|
||||
.option('-s, --server <url>', 'HyperDX API server URL')
|
||||
.option('-a, --app-url <url>', 'HyperDX app URL')
|
||||
.option('-q, --query <query>', 'Initial Lucene search query')
|
||||
.option('--source <name>', 'Source name (skips picker)')
|
||||
.option('-f, --follow', 'Start in follow/live tail mode')
|
||||
.action(opts => {
|
||||
const server = resolveServer(opts.server);
|
||||
render(
|
||||
<App
|
||||
apiUrl={server}
|
||||
query={opts.query}
|
||||
sourceName={opts.source}
|
||||
follow={opts.follow}
|
||||
/>,
|
||||
);
|
||||
.action(async opts => {
|
||||
const server = resolveServer(opts.appUrl);
|
||||
if (!server) {
|
||||
// No saved session and no -a flag — need to login first
|
||||
const client = await ensureSession(undefined);
|
||||
render(
|
||||
<App
|
||||
appUrl={client.getAppUrl()}
|
||||
query={opts.query}
|
||||
sourceName={opts.source}
|
||||
follow={opts.follow}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
render(
|
||||
<App
|
||||
appUrl={server}
|
||||
query={opts.query}
|
||||
sourceName={opts.source}
|
||||
follow={opts.follow}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Auth (login / logout / status) --------------------------------
|
||||
|
|
@ -154,27 +425,37 @@ const auth = program
|
|||
auth
|
||||
.command('login')
|
||||
.description('Sign in to your HyperDX account')
|
||||
.requiredOption('-s, --server <url>', 'HyperDX API server URL')
|
||||
.option('-a, --app-url <url>', 'HyperDX app URL')
|
||||
.option('-e, --email <email>', 'Email address')
|
||||
.option('-p, --password <password>', 'Password')
|
||||
.action(async opts => {
|
||||
const client = new ApiClient({ apiUrl: opts.server });
|
||||
|
||||
if (opts.email && opts.password) {
|
||||
// Non-interactive login (for scripting/CI)
|
||||
// Non-interactive login (for scripting/CI) — app URL is required
|
||||
if (!opts.appUrl) {
|
||||
_origError(
|
||||
chalk.red(
|
||||
`App URL is required for non-interactive login. Use ${chalk.bold('-a <url>')}.\n`,
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const client = new ApiClient({ appUrl: opts.appUrl });
|
||||
const ok = await client.login(opts.email, opts.password);
|
||||
if (ok) {
|
||||
process.stdout.write(
|
||||
chalk.green(`Logged in as ${opts.email} (${opts.server})\n`),
|
||||
chalk.green(`Logged in as ${opts.email} (${opts.appUrl})\n`),
|
||||
);
|
||||
} else {
|
||||
_origError(chalk.red('Login failed. Check your email and password.\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
// Interactive login via Ink
|
||||
// Interactive login via Ink — prompt for app URL if not provided
|
||||
const client = opts.appUrl
|
||||
? new ApiClient({ appUrl: opts.appUrl })
|
||||
: undefined;
|
||||
const { waitUntilExit } = render(
|
||||
<LoginPrompt apiUrl={opts.server} client={client} />,
|
||||
<LoginPrompt initialAppUrl={opts.appUrl} initialClient={client} />,
|
||||
);
|
||||
await waitUntilExit();
|
||||
}
|
||||
|
|
@ -196,19 +477,19 @@ auth
|
|||
if (!session) {
|
||||
process.stdout.write(
|
||||
chalk.yellow(
|
||||
`Not logged in. Run ${chalk.bold('hdx auth login -s <url>')} to sign in.\n`,
|
||||
`Not logged in. Run ${chalk.bold('hdx auth login')} to sign in.\n`,
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new ApiClient({ apiUrl: session.apiUrl });
|
||||
const client = new ApiClient({ appUrl: session.appUrl });
|
||||
const ok = await client.checkSession();
|
||||
|
||||
if (!ok) {
|
||||
process.stdout.write(
|
||||
chalk.yellow(
|
||||
`Session expired. Run ${chalk.bold('hdx auth login -s <url>')} to sign in again.\n`,
|
||||
`Session expired. Run ${chalk.bold('hdx auth login')} to sign in again.\n`,
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
|
|
@ -217,10 +498,10 @@ auth
|
|||
try {
|
||||
const me = await client.getMe();
|
||||
process.stdout.write(
|
||||
`${chalk.green('Logged in')} as ${chalk.bold(me.email)} (${session.apiUrl})\n`,
|
||||
`${chalk.green('Logged in')} as ${chalk.bold(me.email)} (${session.appUrl})\n`,
|
||||
);
|
||||
} catch {
|
||||
process.stdout.write(chalk.green('Logged in') + ` (${session.apiUrl})\n`);
|
||||
process.stdout.write(chalk.green('Logged in') + ` (${session.appUrl})\n`);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -231,7 +512,7 @@ program
|
|||
.description(
|
||||
'List data sources (log, trace, session, metric) with ClickHouse table schemas',
|
||||
)
|
||||
.option('-s, --server <url>', 'HyperDX API server URL')
|
||||
.option('-a, --app-url <url>', 'HyperDX app URL')
|
||||
.option('--json', 'Output as JSON (for programmatic consumption)')
|
||||
.addHelpText(
|
||||
'after',
|
||||
|
|
@ -288,17 +569,7 @@ Examples:
|
|||
`,
|
||||
)
|
||||
.action(async opts => {
|
||||
const server = resolveServer(opts.server);
|
||||
const client = new ApiClient({ apiUrl: server });
|
||||
|
||||
if (!(await client.checkSession())) {
|
||||
_origError(
|
||||
chalk.red(
|
||||
`Not logged in. Run ${chalk.bold('hdx auth login')} to sign in.\n`,
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const client = await ensureSession(opts.appUrl);
|
||||
|
||||
const sources = await client.getSources();
|
||||
if (sources.length === 0) {
|
||||
|
|
@ -398,7 +669,7 @@ Examples:
|
|||
program
|
||||
.command('dashboards')
|
||||
.description('List dashboards with tile summaries')
|
||||
.option('-s, --server <url>', 'HyperDX API server URL')
|
||||
.option('-a, --app-url <url>', 'HyperDX app URL')
|
||||
.option('--json', 'Output as JSON (for programmatic consumption)')
|
||||
.addHelpText(
|
||||
'after',
|
||||
|
|
@ -432,17 +703,7 @@ Examples:
|
|||
`,
|
||||
)
|
||||
.action(async opts => {
|
||||
const server = resolveServer(opts.server);
|
||||
const client = new ApiClient({ apiUrl: server });
|
||||
|
||||
if (!(await client.checkSession())) {
|
||||
_origError(
|
||||
chalk.red(
|
||||
`Not logged in. Run ${chalk.bold('hdx auth login')} to sign in.\n`,
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const client = await ensureSession(opts.appUrl);
|
||||
|
||||
const dashboards = await client.getDashboards();
|
||||
if (dashboards.length === 0) {
|
||||
|
|
@ -526,7 +787,7 @@ program
|
|||
.description('Run a raw SQL query against a ClickHouse source')
|
||||
.requiredOption('--source <nameOrId>', 'Source name or ID')
|
||||
.requiredOption('--sql <query>', 'SQL query to execute')
|
||||
.option('-s, --server <url>', 'HyperDX API server URL')
|
||||
.option('-a, --app-url <url>', 'HyperDX app URL')
|
||||
.option('--format <format>', 'ClickHouse output format', 'JSON')
|
||||
.addHelpText(
|
||||
'after',
|
||||
|
|
@ -553,17 +814,7 @@ Examples:
|
|||
`,
|
||||
)
|
||||
.action(async opts => {
|
||||
const server = resolveServer(opts.server);
|
||||
const client = new ApiClient({ apiUrl: server });
|
||||
|
||||
if (!(await client.checkSession())) {
|
||||
_origError(
|
||||
chalk.red(
|
||||
`Not logged in. Run ${chalk.bold('hdx auth login')} to sign in.\n`,
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const client = await ensureSession(opts.appUrl);
|
||||
|
||||
const sources = await client.getSources();
|
||||
const source = sources.find(
|
||||
|
|
|
|||
|
|
@ -6,19 +6,50 @@ import Spinner from 'ink-spinner';
|
|||
import ErrorDisplay from '@/components/ErrorDisplay';
|
||||
|
||||
interface LoginFormProps {
|
||||
apiUrl: string;
|
||||
onLogin: (email: string, password: string) => Promise<boolean>;
|
||||
/** Default app URL (autofilled, editable by the user). */
|
||||
defaultAppUrl: string;
|
||||
/** Called with the (possibly changed) appUrl, email, and password. */
|
||||
onLogin: (
|
||||
appUrl: string,
|
||||
email: string,
|
||||
password: string,
|
||||
) => Promise<boolean>;
|
||||
/** Optional message shown above the form (e.g. "Session expired"). */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
type Field = 'email' | 'password';
|
||||
type Field = 'appUrl' | 'email' | 'password';
|
||||
|
||||
export default function LoginForm({ apiUrl, onLogin }: LoginFormProps) {
|
||||
const [field, setField] = useState<Field>('email');
|
||||
export default function LoginForm({
|
||||
defaultAppUrl,
|
||||
onLogin,
|
||||
message,
|
||||
}: LoginFormProps) {
|
||||
const [field, setField] = useState<Field>('appUrl');
|
||||
const [appUrl, setAppUrl] = useState(defaultAppUrl);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmitAppUrl = () => {
|
||||
const trimmed = appUrl.trim();
|
||||
if (!trimmed) return;
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
setError('Invalid URL. Please enter a valid http:// or https:// URL.');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
setError('Invalid URL. Please enter a valid http:// or https:// URL.');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setAppUrl(trimmed);
|
||||
setField('email');
|
||||
};
|
||||
|
||||
const handleSubmitEmail = () => {
|
||||
if (!email.trim()) return;
|
||||
setField('password');
|
||||
|
|
@ -28,10 +59,10 @@ export default function LoginForm({ apiUrl, onLogin }: LoginFormProps) {
|
|||
if (!password) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const ok = await onLogin(email, password);
|
||||
const ok = await onLogin(appUrl.trim(), email, password);
|
||||
setLoading(false);
|
||||
if (!ok) {
|
||||
setError('Login failed. Check your email and password.');
|
||||
setError('Login failed. Check your credentials and server URL.');
|
||||
setField('email');
|
||||
setEmail('');
|
||||
setPassword('');
|
||||
|
|
@ -43,7 +74,8 @@ export default function LoginForm({ apiUrl, onLogin }: LoginFormProps) {
|
|||
<Text bold color="cyan">
|
||||
HyperDX TUI — Login
|
||||
</Text>
|
||||
<Text dimColor>Server: {apiUrl}</Text>
|
||||
{message && <Text color="yellow">{message}</Text>}
|
||||
{field !== 'appUrl' && <Text dimColor>Server: {appUrl}</Text>}
|
||||
<Text> </Text>
|
||||
|
||||
{error && <ErrorDisplay error={error} severity="error" compact />}
|
||||
|
|
@ -52,6 +84,16 @@ export default function LoginForm({ apiUrl, onLogin }: LoginFormProps) {
|
|||
<Text>
|
||||
<Spinner type="dots" /> Logging in…
|
||||
</Text>
|
||||
) : field === 'appUrl' ? (
|
||||
<Box>
|
||||
<Text>HyperDX URL: </Text>
|
||||
<TextInput
|
||||
value={appUrl}
|
||||
onChange={setAppUrl}
|
||||
onSubmit={handleSubmitAppUrl}
|
||||
placeholder="http://localhost:8080"
|
||||
/>
|
||||
</Box>
|
||||
) : field === 'email' ? (
|
||||
<Box>
|
||||
<Text>Email: </Text>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ const CONFIG_DIR = path.join(os.homedir(), '.config', 'hyperdx', 'cli');
|
|||
const SESSION_FILE = path.join(CONFIG_DIR, 'session.json');
|
||||
|
||||
export interface SessionConfig {
|
||||
apiUrl: string;
|
||||
appUrl: string;
|
||||
cookies: string[];
|
||||
}
|
||||
|
||||
|
|
@ -27,7 +27,20 @@ export function loadSession(): SessionConfig | null {
|
|||
try {
|
||||
if (!fs.existsSync(SESSION_FILE)) return null;
|
||||
const data = fs.readFileSync(SESSION_FILE, 'utf-8');
|
||||
return JSON.parse(data) as SessionConfig;
|
||||
const raw = JSON.parse(data) as Record<string, unknown>;
|
||||
|
||||
// Migrate legacy sessions that only have apiUrl (no appUrl).
|
||||
// Old sessions stored the API URL directly; new sessions store
|
||||
// the app URL and derive the API URL by appending '/api'.
|
||||
if (!raw.appUrl && typeof raw.apiUrl === 'string') {
|
||||
raw.appUrl = raw.apiUrl.replace(/\/api\/?$/, '');
|
||||
delete raw.apiUrl;
|
||||
const migrated = raw as unknown as SessionConfig;
|
||||
saveSession(migrated);
|
||||
return migrated;
|
||||
}
|
||||
|
||||
return raw as unknown as SessionConfig;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue