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
14 changes: 7 additions & 7 deletions Documentation/GettingStarted/LocalDevelopment/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,15 @@ This generates `Source/dist/`, including `manifest.json`.

The **Lens - Cratis Developer Tools** extension should now appear in your extension list.

## 4. Set up Arc connection
## 4. Open Lens on an Arc application page

1. Open the extension options page from the extension details.
2. Set **Arc Base URL** to your local Arc host, for example `http://localhost:5000`.
3. Keep the default **Tenant Header Name** unless your app expects a different header.
4. Select **Save Settings**.
1. Navigate to your Arc application in Chrome.
2. Open the Lens extension popup once on that page so Lens can detect Arc context.
3. Open the extension options page from the extension details.
4. Keep the default **Tenant Header Name** unless your app expects a different header.
5. Select **Save Settings**.

Lens can now fetch command and query introspection metadata from your Arc application.
Lens now uses the detected Arc context and current page location for command and query introspection.

## 5. Use watch mode while developing

Expand All @@ -57,4 +58,3 @@ npm run dev
```

Vite rebuilds the extension when you change source files. Reload the extension in `chrome://extensions` after each rebuild.

1 change: 1 addition & 0 deletions Source/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"description": "Browser extension bringing Cratis insights and developer productivity tools to your fingertips.",
"permissions": [
"storage",
"scripting",
"declarativeNetRequest",
"declarativeNetRequestWithHostAccess"
],
Expand Down
39 changes: 28 additions & 11 deletions Source/src/options/Options.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { ExtensionSettings } from '../shared/types';
import { getSettings, saveSettings } from '../shared/storage';
import { ArcContextSnapshot, getArcContextSnapshot } from '../shared/arc-context';
import { UserList } from './components/UserList';
import { TenantList } from './components/TenantList';
import { ArcSettings } from './components/ArcSettings';
Expand All @@ -12,11 +13,17 @@ type Tab = 'users' | 'tenants' | 'arc' | 'commands' | 'queries';

export function Options() {
const [settings, setSettings] = useState<ExtensionSettings | null>(null);
const [arcContext, setArcContext] = useState<ArcContextSnapshot | null>(null);
const [arcContextLoading, setArcContextLoading] = useState(true);
const [activeTab, setActiveTab] = useState<Tab>('users');
const [saved, setSaved] = useState(false);
const hasArcContext = arcContext?.isArcApplication === true;

useEffect(() => {
getSettings().then(setSettings);
getArcContextSnapshot()
.then(setArcContext)
.finally(() => setArcContextLoading(false));
}, []);

const handleChange = async (updated: ExtensionSettings) => {
Expand All @@ -30,13 +37,18 @@ export function Options() {
return <div className="loading">Loading settings…</div>;
}

const arcBaseUrl = arcContext?.baseUrl ?? '';

const tabs: { id: Tab; label: string }[] = [
{ id: 'users', label: 'Users' },
{ id: 'tenants', label: 'Tenants' },
{ id: 'arc', label: 'Arc Settings' },
{ id: 'commands', label: 'Commands' },
{ id: 'queries', label: 'Queries' },
];
if (hasArcContext) {
tabs.push({ id: 'commands', label: 'Commands' });
tabs.push({ id: 'queries', label: 'Queries' });
}
const resolvedActiveTab = tabs.some(tab => tab.id === activeTab) ? activeTab : 'users';

return (
<div className="options-root">
Expand All @@ -49,7 +61,7 @@ export function Options() {
{tabs.map(t => (
<button
key={t.id}
className={`tab-btn ${activeTab === t.id ? 'active' : ''}`}
className={`tab-btn ${resolvedActiveTab === t.id ? 'active' : ''}`}
onClick={() => setActiveTab(t.id)}
>
{t.label}
Expand All @@ -58,20 +70,25 @@ export function Options() {
</nav>

<main className="options-main">
{activeTab === 'users' && (
{!arcContextLoading && !hasArcContext && (
<div className="warning-banner">
This is not an Arc application. Open Lens on an Arc application page to enable Commands and Queries.
</div>
)}
{resolvedActiveTab === 'users' && (
<UserList settings={settings} onChange={handleChange} />
)}
{activeTab === 'tenants' && (
{resolvedActiveTab === 'tenants' && (
<TenantList settings={settings} onChange={handleChange} />
)}
{activeTab === 'arc' && (
<ArcSettings settings={settings} onChange={handleChange} />
{resolvedActiveTab === 'arc' && (
<ArcSettings settings={settings} onChange={handleChange} arcContext={arcContext} />
)}
{activeTab === 'commands' && (
<CommandsPanel arcBaseUrl={settings.arcBaseUrl} />
{resolvedActiveTab === 'commands' && hasArcContext && (
<CommandsPanel arcBaseUrl={arcBaseUrl} />
)}
{activeTab === 'queries' && (
<QueriesPanel arcBaseUrl={settings.arcBaseUrl} />
{resolvedActiveTab === 'queries' && hasArcContext && (
<QueriesPanel arcBaseUrl={arcBaseUrl} />
)}
</main>
</div>
Expand Down
24 changes: 20 additions & 4 deletions Source/src/options/components/ArcSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { useState } from 'react';
import { ExtensionSettings } from '../../shared/types';
import { ArcContextSnapshot } from '../../shared/arc-context';

interface Props {
settings: ExtensionSettings;
onChange: (settings: ExtensionSettings) => void;
arcContext: ArcContextSnapshot | null;
}

export function ArcSettings({ settings, onChange }: Props) {
export function ArcSettings({ settings, onChange, arcContext }: Props) {
const [arcBaseUrl, setArcBaseUrl] = useState(settings.arcBaseUrl);
const [tenantHeaderName, setTenantHeaderName] = useState(settings.tenantHeaderName);

Expand All @@ -22,17 +24,31 @@ export function ArcSettings({ settings, onChange }: Props) {

<div className="card">
<div className="card-title">Connection</div>
{arcContext?.isArcApplication && arcContext.baseUrl ? (
<>
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>
Commands and Queries use Arc context detected from the current page.
</p>
<div style={{ fontSize: 12, marginTop: 8, fontFamily: 'Courier New, monospace', color: 'var(--accent)' }}>
{arcContext.baseUrl}
</div>
</>
) : (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>
Arc context is unavailable for the current page. Commands and Queries are hidden until Lens is opened on an Arc application page.
</p>
)}

<div className="form-row">
<label>Arc Base URL</label>
<div className="form-row" style={{ marginTop: 16 }}>
<label>Arc Base URL (Header Injection Scope)</label>
<input
type="url"
value={arcBaseUrl}
onChange={e => setArcBaseUrl(e.target.value)}
placeholder="http://localhost:5000"
/>
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 6 }}>
The base URL of your Arc application. Used to fetch introspection data and to scope header injection.
Optional scope used for request header injection rules.
</p>
</div>
</div>
Expand Down
37 changes: 22 additions & 15 deletions Source/src/options/components/CommandsPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { CommandIntrospectionMetadata } from '../../shared/types';
import { ARC_CONTEXT_UNAVAILABLE_MESSAGE } from './arc-panel-constants';

interface Props {
arcBaseUrl: string;
Expand All @@ -19,17 +20,22 @@ interface CommandState {
}

export function CommandsPanel({ arcBaseUrl }: Props) {
const [baseUrl, setBaseUrl] = useState(arcBaseUrl);
const [commands, setCommands] = useState<CommandIntrospectionMetadata[] | null>(null);
const [fetchError, setFetchError] = useState<string | null>(null);
const [fetching, setFetching] = useState(false);
const [states, setStates] = useState<Record<string, CommandState>>({});

const fetchCommands = useCallback(async () => {
if (!arcBaseUrl) {
setCommands(null);
setFetchError(ARC_CONTEXT_UNAVAILABLE_MESSAGE);
return;
}

setFetching(true);
setFetchError(null);
try {
const url = `${baseUrl.replace(/\/$/, '')}/.cratis/commands`;
const url = `${arcBaseUrl.replace(/\/$/, '')}/.cratis/commands`;
const res = await fetch(url);
if (!res.ok) {
setFetchError(`HTTP ${res.status}: ${res.statusText}`);
Expand All @@ -47,7 +53,11 @@ export function CommandsPanel({ arcBaseUrl }: Props) {
} finally {
setFetching(false);
}
}, [baseUrl]);
}, [arcBaseUrl]);

useEffect(() => {
void fetchCommands();
}, [fetchCommands]);

const toggleExpanded = (type: string) => {
setStates(prev => ({
Expand All @@ -63,7 +73,7 @@ export function CommandsPanel({ arcBaseUrl }: Props) {
const invoke = async (cmd: CommandIntrospectionMetadata) => {
setStates(prev => ({ ...prev, [cmd.type]: { ...prev[cmd.type], loading: true, result: null } }));
try {
const url = `${baseUrl.replace(/\/$/, '')}${cmd.route}`;
const url = `${arcBaseUrl.replace(/\/$/, '')}${cmd.route}`;
const state = states[cmd.type];
let bodyData: BodyInit | null = null;
try {
Expand Down Expand Up @@ -104,14 +114,11 @@ export function CommandsPanel({ arcBaseUrl }: Props) {

<div className="card">
<div className="arc-url-row">
<input
type="url"
value={baseUrl}
onChange={e => setBaseUrl(e.target.value)}
placeholder="http://localhost:5000"
/>
<button className="btn btn-primary" onClick={fetchCommands} disabled={fetching || !baseUrl}>
{fetching ? 'Loading…' : 'Fetch Commands'}
<div style={{ fontSize: 12, color: 'var(--text-muted)', fontFamily: 'Courier New, monospace' }}>
{arcBaseUrl}/.cratis/commands
</div>
<button className="btn btn-primary" onClick={fetchCommands} disabled={fetching || !arcBaseUrl}>
{fetching ? 'Loading…' : 'Refresh'}
</button>
</div>
{fetchError && (
Expand All @@ -123,13 +130,13 @@ export function CommandsPanel({ arcBaseUrl }: Props) {

{commands === null && !fetchError && (
<div className="empty-state">
<p>Enter your Arc base URL and click &quot;Fetch Commands&quot; to discover available commands.</p>
<p>Loading commands from Arc introspection…</p>
</div>
)}

{commands !== null && commands.length === 0 && (
<div className="empty-state">
<p>No commands discovered from <code>{baseUrl}/.cratis/commands</code>.</p>
<p>No commands discovered from <code>{arcBaseUrl}/.cratis/commands</code>.</p>
</div>
)}

Expand Down
39 changes: 23 additions & 16 deletions Source/src/options/components/QueriesPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { QueryIntrospectionMetadata } from '../../shared/types';
import { ARC_CONTEXT_UNAVAILABLE_MESSAGE } from './arc-panel-constants';

interface Props {
arcBaseUrl: string;
Expand Down Expand Up @@ -32,17 +33,22 @@ function buildUrl(baseUrl: string, route: string, params: Record<string, string>
}

export function QueriesPanel({ arcBaseUrl }: Props) {
const [baseUrl, setBaseUrl] = useState(arcBaseUrl);
const [queries, setQueries] = useState<QueryIntrospectionMetadata[] | null>(null);
const [fetchError, setFetchError] = useState<string | null>(null);
const [fetching, setFetching] = useState(false);
const [states, setStates] = useState<Record<string, QueryState>>({});

const fetchQueries = useCallback(async () => {
if (!arcBaseUrl) {
setQueries(null);
setFetchError(ARC_CONTEXT_UNAVAILABLE_MESSAGE);
return;
}

setFetching(true);
setFetchError(null);
try {
const url = `${baseUrl.replace(/\/$/, '')}/.cratis/queries`;
const url = `${arcBaseUrl.replace(/\/$/, '')}/.cratis/queries`;
const res = await fetch(url);
if (!res.ok) {
setFetchError(`HTTP ${res.status}: ${res.statusText}`);
Expand All @@ -63,7 +69,11 @@ export function QueriesPanel({ arcBaseUrl }: Props) {
} finally {
setFetching(false);
}
}, [baseUrl]);
}, [arcBaseUrl]);

useEffect(() => {
void fetchQueries();
}, [fetchQueries]);

const toggleExpanded = (type: string) => {
setStates(prev => ({
Expand All @@ -83,7 +93,7 @@ export function QueriesPanel({ arcBaseUrl }: Props) {
setStates(prev => ({ ...prev, [query.type]: { ...prev[query.type], loading: true, result: null } }));
try {
const state = states[query.type];
const url = buildUrl(baseUrl, query.route, state.params);
const url = buildUrl(arcBaseUrl, query.route, state.params);
const res = await fetch(url, { method: 'GET' });
let body = '';
const contentType = res.headers.get('content-type') ?? '';
Expand Down Expand Up @@ -112,14 +122,11 @@ export function QueriesPanel({ arcBaseUrl }: Props) {

<div className="card">
<div className="arc-url-row">
<input
type="url"
value={baseUrl}
onChange={e => setBaseUrl(e.target.value)}
placeholder="http://localhost:5000"
/>
<button className="btn btn-primary" onClick={fetchQueries} disabled={fetching || !baseUrl}>
{fetching ? 'Loading…' : 'Fetch Queries'}
<div style={{ fontSize: 12, color: 'var(--text-muted)', fontFamily: 'Courier New, monospace' }}>
{arcBaseUrl}/.cratis/queries
</div>
<button className="btn btn-primary" onClick={fetchQueries} disabled={fetching || !arcBaseUrl}>
{fetching ? 'Loading…' : 'Refresh'}
</button>
</div>
{fetchError && (
Expand All @@ -131,13 +138,13 @@ export function QueriesPanel({ arcBaseUrl }: Props) {

{queries === null && !fetchError && (
<div className="empty-state">
<p>Enter your Arc base URL and click &quot;Fetch Queries&quot; to discover available queries.</p>
<p>Loading queries from Arc introspection…</p>
</div>
)}

{queries !== null && queries.length === 0 && (
<div className="empty-state">
<p>No queries discovered from <code>{baseUrl}/.cratis/queries</code>.</p>
<p>No queries discovered from <code>{arcBaseUrl}/.cratis/queries</code>.</p>
</div>
)}

Expand All @@ -146,7 +153,7 @@ export function QueriesPanel({ arcBaseUrl }: Props) {
{queries.map(query => {
const state = states[query.type] ?? { expanded: false, params: {}, result: null, loading: false };
const paramNames = extractPathParams(query.route);
const finalUrl = buildUrl(baseUrl, query.route, state.params);
const finalUrl = buildUrl(arcBaseUrl, query.route, state.params);

return (
<div className="endpoint-card" key={query.type}>
Expand Down
1 change: 1 addition & 0 deletions Source/src/options/components/arc-panel-constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const ARC_CONTEXT_UNAVAILABLE_MESSAGE = 'Arc base URL unavailable. Open Lens on an Arc application page.';
12 changes: 12 additions & 0 deletions Source/src/options/options.css
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,16 @@ body {
min-height: 400px;
}

.warning-banner {
background: rgba(249, 226, 175, 0.08);
border: 1px solid var(--accent-yellow);
border-radius: var(--radius-sm);
color: var(--accent-yellow);
font-size: 13px;
padding: 10px 12px;
margin-bottom: 16px;
}

/* Cards */
.card {
background: var(--bg-card);
Expand Down Expand Up @@ -382,6 +392,8 @@ select {
/* Arc panel */
.arc-url-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 16px;
}
Expand Down
Loading