Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/web/__tests__/components/backup-restore/file_test.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 38 additions & 0 deletions apps/web/__tests__/components/site-branding_test.tsx

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions apps/web/src/api/site-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { InferResponseType } from 'hono/client';

import { api, callApi, type ApiResult } from './client';

export type SiteSettings = InferResponseType<typeof api.api['site-settings']['$get'], 200>;
type UpdateSiteSettingsResponse = InferResponseType<typeof api.api['site-settings']['$put'], 200>;

export const getSiteSettings = (): Promise<ApiResult<SiteSettings>> =>
callApi(() => api.api['site-settings'].$get());

export const updateSiteSettings = (settings: SiteSettings): Promise<ApiResult<UpdateSiteSettingsResponse>> =>
callApi(() => api.api['site-settings'].$put({ json: settings }));
3 changes: 2 additions & 1 deletion apps/web/src/components/backup-restore/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { errorMessage } from '../../lib/error-message';

// Annotated with the gateway's own literal so a bump there fails this
// assignment instead of silently rejecting every backup the deployment writes.
export const BACKUP_FILE_VERSION: InferResponseType<typeof api.api.export.$get, 200>['version'] = 20;
export const BACKUP_FILE_VERSION: InferResponseType<typeof api.api.export.$get, 200>['version'] = 21;

const backupFileSchema = z.object({
version: z.literal(BACKUP_FILE_VERSION),
Expand All @@ -21,6 +21,7 @@ const backupFileSchema = z.object({
performance: z.array(z.unknown()).optional(),
performanceIncluded: z.boolean(),
searchConfig: z.unknown(),
siteSettings: z.unknown(),
}).strict().superRefine((data, ctx) => {
if (data.performanceIncluded !== (data.performance !== undefined)) {
ctx.addIssue({
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/components/document-title-sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useLocation } from 'react-router';

import { useTranslation } from '../i18n/translation';
import { pageLabelKeys } from './sidebar/pages';
import { useSiteSettings } from './site-settings-context';

const titleKeyForPathname = (pathname: string) => {
if (pathname === '/') return 'auth.login.title';
Expand All @@ -24,11 +25,12 @@ const titleKeyForPathname = (pathname: string) => {
export function DocumentTitleSync() {
const location = useLocation();
const { i18n, t } = useTranslation();
const { name } = useSiteSettings();

useEffect(() => {
const title = t(titleKeyForPathname(location.pathname));
window.document.title = t('app.documentTitle', { title });
}, [i18n.language, location.pathname, t]);
window.document.title = t('app.documentTitle', { siteName: name, title });
}, [i18n.language, location.pathname, name, t]);

return null;
}
4 changes: 2 additions & 2 deletions apps/web/src/components/logo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ const useMarkStyles = makeStyles({
glyph: { display: 'block', height: '24px', width: '24px' },
});

export function FlowayLogo() {
export function FlowayLogo({ name = 'Floway' }: { name?: string }) {
const ms = useMarkStyles();
const mark = currentMark();

Expand All @@ -60,7 +60,7 @@ export function FlowayLogo() {
<span
className="font-fui-semibold text-fui-base500 leading-[var(--lineHeightBase500)]"
>
Floway
{name}
</span>
</div>
);
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/sidebar/nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ function SidebarLink({ children, icon, onNavigate, pending, to }: {

const AccountIcon = accountPage.icon;

export function Sidebar({ onNavigate, user }: { onNavigate?: () => void; user: AuthUser }) {
export function Sidebar({ onNavigate, siteName, user }: { onNavigate?: () => void; siteName: string; user: AuthUser }) {
const { t } = useTranslation();
const { pathname } = useLocation();
const navigation = useNavigation();
Expand Down Expand Up @@ -127,7 +127,7 @@ export function Sidebar({ onNavigate, user }: { onNavigate?: () => void; user: A
>
<NavDrawerHeader className="!bg-transparent !px-5 !py-4">
<div className="flex items-center min-h-10">
<FlowayLogo />
<FlowayLogo name={siteName} />
{onNavigate && <Button appearance="subtle" aria-label={t('dashboard.nav.close')} className="!ml-auto" icon={<DismissRegular />} onClick={onNavigate} />}
</div>
</NavDrawerHeader>
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/sidebar/pages.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
Chat20Color,
BuildingHome20Color,
Clipboard20Color,
Cloud20Color,
Database20Color,
Expand Down Expand Up @@ -72,6 +73,7 @@ export const navGroups: NavGroup[] = [
adminOnly: true,
items: [
{ to: '/dashboard/admin/users', labelKey: 'dashboard.nav.users', icon: People20Color },
{ to: '/dashboard/admin/site', labelKey: 'dashboard.nav.site', icon: BuildingHome20Color },
{ to: '/dashboard/admin/backup-restore', labelKey: 'dashboard.nav.backupRestore', icon: Database20Color },
],
},
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/components/site-settings-context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { createContext, useContext } from 'react';

import type { SiteSettings } from '../api/site-settings';

const SiteSettingsContext = createContext<SiteSettings | null>(null);

export function SiteSettingsProvider({ children, value }: { children: React.ReactNode; value: SiteSettings }) {
return <SiteSettingsContext value={value}>{children}</SiteSettingsContext>;
}

export function useSiteSettings(): SiteSettings {
const settings = useContext(SiteSettingsContext);
if (!settings) throw new Error('useSiteSettings must be used within SiteSettingsProvider');
return settings;
}
17 changes: 16 additions & 1 deletion apps/web/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const en = {
translation: {
app: {
title: 'Floway',
documentTitle: '{{title}} | Floway',
documentTitle: '{{title}} | {{siteName}}',
},
common: {
loading: shellLoadingLabel,
Expand Down Expand Up @@ -81,6 +81,7 @@ const en = {
usage: 'Usage',
performance: 'Performance',
users: 'Users',
site: 'Site',
backupRestore: 'Backup / Restore',
settings: 'Settings',
},
Expand All @@ -107,10 +108,24 @@ const en = {
'Monitor latency, throughput, and upstream performance signals',
users:
'Manage console users, permissions, telemetry access, and upstream scopes',
site:
'Choose the site name shown in the dashboard and browser title',
backupRestore:
'Download a full copy of everything this gateway holds, or restore it from an earlier export',
unavailable: 'This view could not be loaded',
},
siteSettings: {
heading: 'Site identity',
description: 'The mark stays the same; only the name beside it and in browser titles changes.',
name: 'Site name',
nameHint: 'Use 1 to 64 characters.',
save: 'Save',
saved: 'Site name updated',
validation: {
required: 'Enter a site name.',
max: 'Site name must be 64 characters or fewer.',
},
},
playground: {
system: 'Custom system prompt',
systemPlaceholder: 'Optional instructions for the model',
Expand Down
16 changes: 15 additions & 1 deletion apps/web/src/i18n/locales/zh-Hans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ const zhHansCN = {
translation: {
app: {
title: 'Floway',
documentTitle: '{{title}} | Floway',
documentTitle: '{{title}} | {{siteName}}',
},
common: {
loading: '加载中…',
Expand Down Expand Up @@ -79,6 +79,7 @@ const zhHansCN = {
usage: '使用量',
performance: '性能',
users: '用户',
site: '站点',
backupRestore: '备份 / 恢复',
settings: '设置',
},
Expand All @@ -98,9 +99,22 @@ const zhHansCN = {
usage: '按用户、API 密钥、模型和上游查看 token 用量与流量',
performance: '观察延迟、吞吐和上游性能信号',
users: '管理控制台用户、权限、观测访问和上游范围',
site: '设置 Dashboard 左上角和浏览器标题中显示的站点名称',
backupRestore: '下载此网关全部数据的完整副本,或从此前的导出中恢复',
unavailable: '无法加载此视图',
},
siteSettings: {
heading: '站点标识',
description: '图标保持不变,只修改旁边的名称和浏览器标题中的名称。',
name: '站点名称',
nameHint: '可输入 1 至 64 个字符。',
save: '保存',
saved: '站点名称已更新',
validation: {
required: '请输入站点名称。',
max: '站点名称不能超过 64 个字符。',
},
},
playground: {
system: '自定义系统提示词',
systemPlaceholder: '可选的模型指令',
Expand Down
14 changes: 11 additions & 3 deletions apps/web/src/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ import criticalCss from 'virtual:floway-critical.css?inline';
import winuiStylesheet from 'virtual:floway-winui.css?url';

import type { Route } from './+types/root';
import { getSiteSettings } from './api/site-settings';
import { BrowserLanguageSync } from './components/browser-language-sync';
import { DocumentTitleSync } from './components/document-title-sync';
import { GradientBackground } from './components/gradient-background';
import { markPickerScript } from './components/logo-mark';
import { NavigationProgress } from './components/navigation-progress';
import { SiteSettingsProvider } from './components/site-settings-context';
import { ErrorShell, ErrorStack } from './components/ui/error-shell';
import { AppLoadingScreen } from './components/ui/loading-screen';
import { fluentComponents } from './fluent';
Expand Down Expand Up @@ -82,13 +84,19 @@ export function Layout({ children }: { children: React.ReactNode }) {
);
}

export default function App() {
export async function clientLoader() {
const result = await getSiteSettings();
if (result.error) throw new Error(result.error.message, { cause: result.error.cause });
return result.data;
}

export default function App({ loaderData }: Route.ComponentProps) {
return (
<>
<SiteSettingsProvider value={loaderData}>
<NavigationProgress />
<DocumentTitleSync />
<Outlet />
</>
</SiteSettingsProvider>
);
}

Expand Down
1 change: 1 addition & 0 deletions apps/web/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export default [
route('monitor/usage', 'routes/dashboard-monitor-usage.tsx'),
route('monitor/performance', 'routes/dashboard-monitor-performance.tsx'),
route('admin/users', 'routes/dashboard-admin-users.tsx'),
route('admin/site', 'routes/dashboard-admin-site.tsx'),
route('admin/backup-restore', 'routes/dashboard-admin-backup-restore.tsx'),
route('settings', 'routes/dashboard-settings.tsx'),
...developmentRoutes,
Expand Down
95 changes: 95 additions & 0 deletions apps/web/src/routes/dashboard-admin-site.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { z } from 'zod';

import { requireDashboardAdmin } from './guards';
import { updateSiteSettings } from '../api/site-settings';
import { useSiteSettings } from '../components/site-settings-context';
import { DashboardPageHeader } from '../components/ui/dashboard-page-header';
import { Input } from '../components/ui/fluent-form-controls';
import { PANEL_STACK_CLASS } from '../components/ui/layout';
import { OutcomeMessageBar } from '../components/ui/outcome-message-bar';
import { useOutcomeToasts } from '../components/ui/outcome-toast';
import { Panel } from '../components/ui/panel';
import { SectionHeader } from '../components/ui/section-header';
import { fluentComponents } from '../fluent';
import { useTranslation } from '../i18n/translation';

const { Button, Field } = fluentComponents;

export async function clientLoader() {
await requireDashboardAdmin();
return null;
}

const siteNameSchema = z.object({
name: z.string()
.trim()
.min(1, 'dashboard.siteSettings.validation.required')
.max(64, 'dashboard.siteSettings.validation.max'),
});

type SiteNameFormValues = z.infer<typeof siteNameSchema>;

export default function DashboardAdminSite() {
const { name } = useSiteSettings();
const { t } = useTranslation();
const revalidator = useRevalidator();
const toasts = useOutcomeToasts();
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const {
formState: { errors },
handleSubmit,
register,
} = useForm<SiteNameFormValues>({
resolver: zodResolver(siteNameSchema),
values: { name },
});

const save = async (values: SiteNameFormValues) => {
if (saving) return;
setSaving(true);
setError(null);
const result = await updateSiteSettings(values);
if (result.error) {
setError(result.error.message);
setSaving(false);
return;
}
await revalidator.revalidate();
setSaving(false);
toasts.succeed(t('dashboard.siteSettings.saved'));
};

return (
<section className="dashboard-page max-w-[960px]">
<DashboardPageHeader description={t('dashboard.pages.site')} title={t('dashboard.nav.site')} />

<Panel className={`${PANEL_STACK_CLASS} w-full max-w-[480px]`}>
<SectionHeader description={t('dashboard.siteSettings.description')} level={2} title={t('dashboard.siteSettings.heading')} />

<form className="grid gap-4" onSubmit={event => void handleSubmit(save)(event)}>
<Field
hint={t('dashboard.siteSettings.nameHint')}
label={t('dashboard.siteSettings.name')}
validationMessage={errors.name?.message ? t(errors.name.message) : undefined}
validationState={errors.name ? 'error' : undefined}
>
<Input {...register('name')} autoComplete="off" disabled={saving} maxLength={64} />
</Field>

{error && <OutcomeMessageBar onDismiss={() => setError(null)}>{error}</OutcomeMessageBar>}

<div className="flex justify-end pt-1">
<Button appearance="primary" disabledFocusable={saving} type="submit">
{t('dashboard.siteSettings.save')}
</Button>
</div>
</form>
</Panel>
</section>
);
}
Loading