fix: resolve control UI overview merge conflict

This commit is contained in:
Marc J Saint-jour 2026-03-12 18:57:03 -04:00
parent 2d10d72524
commit ed6a06f6ce

View File

@ -1,12 +1,29 @@
import { html } from "lit";
import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js";
import { html, nothing } from "lit";
import { t, i18n, SUPPORTED_LOCALES, type Locale } from "../../i18n/index.ts";
import type { EventLogEntry } from "../app-events.ts";
import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../external-link.ts";
import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts";
import type { GatewayHelloOk } from "../gateway.ts";
import { formatNextRun } from "../presenter.ts";
import { icons } from "../icons.ts";
import type { UiSettings } from "../storage.ts";
import { shouldShowPairingHint } from "./overview-hints.ts";
import type {
AttentionItem,
CronJob,
CronStatus,
SessionsListResult,
SessionsUsageResult,
SkillStatusReport,
} from "../types.ts";
import { renderOverviewAttention } from "./overview-attention.ts";
import { renderOverviewCards } from "./overview-cards.ts";
import { renderOverviewEventLog } from "./overview-event-log.ts";
import {
shouldShowAuthHint,
shouldShowAuthRequiredHint,
shouldShowInsecureContextHint,
shouldShowPairingHint,
} from "./overview-hints.ts";
import { renderOverviewLogTail } from "./overview-log-tail.ts";
export type OverviewProps = {
connected: boolean;
@ -20,21 +37,35 @@ export type OverviewProps = {
cronEnabled: boolean | null;
cronNext: number | null;
lastChannelsRefresh: number | null;
// New dashboard data
usageResult: SessionsUsageResult | null;
sessionsResult: SessionsListResult | null;
skillsReport: SkillStatusReport | null;
cronJobs: CronJob[];
cronStatus: CronStatus | null;
attentionItems: AttentionItem[];
eventLog: EventLogEntry[];
overviewLogLines: string[];
showGatewayToken: boolean;
showGatewayPassword: boolean;
onSettingsChange: (next: UiSettings) => void;
onPasswordChange: (next: string) => void;
onSessionKeyChange: (next: string) => void;
onToggleGatewayTokenVisibility: () => void;
onToggleGatewayPasswordVisibility: () => void;
onConnect: () => void;
onRefresh: () => void;
onOpenCortexPreview: () => void;
onOpenCortexConflicts: () => void;
onOpenCortexSync: () => void;
onNavigate: (tab: string) => void;
onRefreshLogs: () => void;
};
export function renderOverview(props: OverviewProps) {
const snapshot = props.hello?.snapshot as
| {
uptimeMs?: number;
policy?: { tickIntervalMs?: number };
authMode?: "none" | "token" | "password" | "trusted-proxy";
cortex?: {
enabled?: boolean;
@ -48,8 +79,9 @@ export function renderOverview(props: OverviewProps) {
}
| undefined;
const uptime = snapshot?.uptimeMs ? formatDurationHuman(snapshot.uptimeMs) : t("common.na");
const tick = snapshot?.policy?.tickIntervalMs
? `${snapshot.policy.tickIntervalMs}ms`
const tickIntervalMs = props.hello?.policy?.tickIntervalMs;
const tick = tickIntervalMs
? `${(tickIntervalMs / 1000).toFixed(tickIntervalMs % 1000 === 0 ? 0 : 1)}s`
: t("common.na");
const authMode = snapshot?.authMode;
const isTrustedProxy = authMode === "trusted-proxy";
@ -95,38 +127,12 @@ export function renderOverview(props: OverviewProps) {
if (props.connected || !props.lastError) {
return null;
}
const lower = props.lastError.toLowerCase();
const authRequiredCodes = new Set<string>([
ConnectErrorDetailCodes.AUTH_REQUIRED,
ConnectErrorDetailCodes.AUTH_TOKEN_MISSING,
ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING,
ConnectErrorDetailCodes.AUTH_TOKEN_NOT_CONFIGURED,
ConnectErrorDetailCodes.AUTH_PASSWORD_NOT_CONFIGURED,
]);
const authFailureCodes = new Set<string>([
...authRequiredCodes,
ConnectErrorDetailCodes.AUTH_UNAUTHORIZED,
ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH,
ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH,
ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH,
ConnectErrorDetailCodes.AUTH_RATE_LIMITED,
ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISSING,
ConnectErrorDetailCodes.AUTH_TAILSCALE_PROXY_MISSING,
ConnectErrorDetailCodes.AUTH_TAILSCALE_WHOIS_FAILED,
ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISMATCH,
]);
const authFailed = props.lastErrorCode
? authFailureCodes.has(props.lastErrorCode)
: lower.includes("unauthorized") || lower.includes("connect failed");
if (!authFailed) {
if (!shouldShowAuthHint(props.connected, props.lastError, props.lastErrorCode)) {
return null;
}
const hasToken = Boolean(props.settings.token.trim());
const hasPassword = Boolean(props.password.trim());
const isAuthRequired = props.lastErrorCode
? authRequiredCodes.has(props.lastErrorCode)
: !hasToken && !hasPassword;
if (isAuthRequired) {
if (shouldShowAuthRequiredHint(hasToken, hasPassword, props.lastErrorCode)) {
return html`
<div class="muted" style="margin-top: 8px">
${t("overview.auth.required")}
@ -172,15 +178,7 @@ export function renderOverview(props: OverviewProps) {
if (isSecureContext) {
return null;
}
const lower = props.lastError.toLowerCase();
const insecureContextCode =
props.lastErrorCode === ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED ||
props.lastErrorCode === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED;
if (
!insecureContextCode &&
!lower.includes("secure context") &&
!lower.includes("device identity required")
) {
if (!shouldShowInsecureContextHint(props.connected, props.lastError, props.lastErrorCode)) {
return null;
}
return html`
@ -215,18 +213,22 @@ export function renderOverview(props: OverviewProps) {
const currentLocale = i18n.getLocale();
return html`
<section class="grid grid-cols-2">
<section class="grid">
<div class="card">
<div class="card-title">${t("overview.access.title")}</div>
<div class="card-sub">${t("overview.access.subtitle")}</div>
<div class="form-grid" style="margin-top: 16px;">
<label class="field">
<div class="ov-access-grid" style="margin-top: 16px;">
<label class="field ov-access-grid__full">
<span>${t("overview.access.wsUrl")}</span>
<input
.value=${props.settings.gatewayUrl}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
props.onSettingsChange({ ...props.settings, gatewayUrl: v });
props.onSettingsChange({
...props.settings,
gatewayUrl: v,
token: v.trim() === props.settings.gatewayUrl.trim() ? props.settings.token : "",
});
}}
placeholder="ws://100.x.y.z:18789"
/>
@ -237,26 +239,57 @@ export function renderOverview(props: OverviewProps) {
: html`
<label class="field">
<span>${t("overview.access.token")}</span>
<input
.value=${props.settings.token}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
props.onSettingsChange({ ...props.settings, token: v });
}}
placeholder="OPENCLAW_GATEWAY_TOKEN"
/>
<div style="display: flex; align-items: center; gap: 8px;">
<input
type=${props.showGatewayToken ? "text" : "password"}
autocomplete="off"
style="flex: 1;"
.value=${props.settings.token}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
props.onSettingsChange({ ...props.settings, token: v });
}}
placeholder="OPENCLAW_GATEWAY_TOKEN"
/>
<button
type="button"
class="btn btn--icon ${props.showGatewayToken ? "active" : ""}"
style="width: 36px; height: 36px;"
title=${props.showGatewayToken ? "Hide token" : "Show token"}
aria-label="Toggle token visibility"
aria-pressed=${props.showGatewayToken}
@click=${props.onToggleGatewayTokenVisibility}
>
${props.showGatewayToken ? icons.eye : icons.eyeOff}
</button>
</div>
</label>
<label class="field">
<span>${t("overview.access.password")}</span>
<input
type="password"
.value=${props.password}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
props.onPasswordChange(v);
}}
placeholder="system or shared password"
/>
<div style="display: flex; align-items: center; gap: 8px;">
<input
type=${props.showGatewayPassword ? "text" : "password"}
autocomplete="off"
style="flex: 1;"
.value=${props.password}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
props.onPasswordChange(v);
}}
placeholder="system or shared password"
/>
<button
type="button"
class="btn btn--icon ${props.showGatewayPassword ? "active" : ""}"
style="width: 36px; height: 36px;"
title=${props.showGatewayPassword ? "Hide password" : "Show password"}
aria-label="Toggle password visibility"
aria-pressed=${props.showGatewayPassword}
@click=${props.onToggleGatewayPasswordVisibility}
>
${props.showGatewayPassword ? icons.eye : icons.eyeOff}
</button>
</div>
</label>
`
}
@ -294,6 +327,30 @@ export function renderOverview(props: OverviewProps) {
isTrustedProxy ? t("overview.access.trustedProxy") : t("overview.access.connectHint")
}</span>
</div>
${
!props.connected
? html`
<div class="login-gate__help" style="margin-top: 16px;">
<div class="login-gate__help-title">${t("overview.connection.title")}</div>
<ol class="login-gate__steps">
<li>${t("overview.connection.step1")}<code>openclaw gateway run</code></li>
<li>${t("overview.connection.step2")}<code>openclaw dashboard --no-open</code></li>
<li>${t("overview.connection.step3")}</li>
<li>${t("overview.connection.step4")}<code>openclaw doctor --generate-gateway-token</code></li>
</ol>
<div class="login-gate__docs">
${t("overview.connection.docsHint")}
<a
class="session-link"
href="https://docs.openclaw.ai/web/dashboard"
target="_blank"
rel="noreferrer"
>${t("overview.connection.docsLink")}</a>
</div>
</div>
`
: nothing
}
</div>
<div class="card">
@ -360,45 +417,32 @@ export function renderOverview(props: OverviewProps) {
</div>
</section>
<section class="grid grid-cols-3" style="margin-top: 18px;">
<div class="card stat-card">
<div class="stat-label">${t("overview.stats.instances")}</div>
<div class="stat-value">${props.presenceCount}</div>
<div class="muted">${t("overview.stats.instancesHint")}</div>
</div>
<div class="card stat-card">
<div class="stat-label">${t("overview.stats.sessions")}</div>
<div class="stat-value">${props.sessionsCount ?? t("common.na")}</div>
<div class="muted">${t("overview.stats.sessionsHint")}</div>
</div>
<div class="card stat-card">
<div class="stat-label">${t("overview.stats.cron")}</div>
<div class="stat-value">
${props.cronEnabled == null ? t("common.na") : props.cronEnabled ? t("common.enabled") : t("common.disabled")}
</div>
<div class="muted">${t("overview.stats.cronNext", { time: formatNextRun(props.cronNext) })}</div>
</div>
</section>
<div class="ov-section-divider"></div>
${renderOverviewCards({
usageResult: props.usageResult,
sessionsResult: props.sessionsResult,
skillsReport: props.skillsReport,
cronJobs: props.cronJobs,
cronStatus: props.cronStatus,
presenceCount: props.presenceCount,
onNavigate: props.onNavigate,
})}
${renderOverviewAttention({ items: props.attentionItems })}
<div class="ov-section-divider"></div>
<div class="ov-bottom-grid" style="margin-top: 18px;">
${renderOverviewEventLog({
events: props.eventLog,
})}
${renderOverviewLogTail({
lines: props.overviewLogLines,
onRefreshLogs: props.onRefreshLogs,
})}
</div>
<section class="card" style="margin-top: 18px;">
<div class="card-title">${t("overview.notes.title")}</div>
<div class="card-sub">${t("overview.notes.subtitle")}</div>
<div class="note-grid" style="margin-top: 14px;">
<div>
<div class="note-title">${t("overview.notes.tailscaleTitle")}</div>
<div class="muted">
${t("overview.notes.tailscaleText")}
</div>
</div>
<div>
<div class="note-title">${t("overview.notes.sessionTitle")}</div>
<div class="muted">${t("overview.notes.sessionText")}</div>
</div>
<div>
<div class="note-title">${t("overview.notes.cronTitle")}</div>
<div class="muted">${t("overview.notes.cronText")}</div>
</div>
</div>
</section>
`;
}