Skip to content

Commit d990ca1

Browse files
jubaoliangjubaoliang-tencentcursoragent
authored
feat: backup restore rehydrate, server timezone API, and scoped provider reload (#34)
Restore syncs providers and reloads agents in-process; rename cron_timezone to default_timezone with GET /api/settings/timezone; reload only impacted agents after provider changes; align dashboard timestamps to the server timezone. Co-authored-by: jubaoliang <jubaoliang@tencent.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 43c251f commit d990ca1

54 files changed

Lines changed: 3559 additions & 2628 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,20 @@ i18n/
257257

258258
Do **not** use gettext (`.po` files). Do **not** embed user-visible English in `infra/` when a backend i18n key exists. Expert catalog (`infra/agents/experts/`) still uses embedded `label_zh`/`label_en` in source data — out of scope unless explicitly migrating that catalog.
259259

260+
**Timezone (config.json):**
261+
262+
User-facing datetime display and cron/scheduling defaults MUST use the server timezone from `config.json``default_timezone` (env override `OCTOP_DEFAULT_TIMEZONE`; legacy `cron_timezone` / `OCTOP_CRON_TIMEZONE` still accepted) — not the browser's local timezone.
263+
264+
| Layer | Entry |
265+
|-------|--------|
266+
| Config | `OctopConfig.default_timezone` in `config.py` |
267+
| API | `GET /api/settings/timezone``{ "timezone": "…" }` (`GET /api/cron/settings` remains a compat alias) |
268+
| Dashboard | `useServerTimezone()`; format with `formatServerDateTime` / `formatServerIsoDateTime` / `formatMessageTime(..., timeZone)` in `dashboard/src/utils/formatMessageTime.ts` |
269+
270+
**Do not** use bare `toLocaleString()` / `toLocaleDateString()` / `toLocaleTimeString()` for timestamps users see without passing the server `timeZone`. Number grouping via `Number#toLocaleString()` (e.g. download counts) is fine.
271+
272+
**Language:** Keep the existing locale resolution (user preference → `Accept-Language` / dashboard i18n → channel hints → `DEFAULT_LOCALE`). Language is **not** a `config.json` key — do not add one unless explicitly requested.
273+
260274
**Workspace storage:** `infra/backend/resolver.py` resolves harness `BackendProtocol` from agent config; remote backends use `probe.py` for fast tree listing.
261275

262276
**Connectors:** `infra/connectors/` — catalog, OAuth registry, MCP gateway; HTTP surface in `api/routers/connectors.py`.
@@ -312,6 +326,7 @@ Boundary rules are in [§5](#5-module-boundaries). Additionally:
312326
| How does the frontend call the API? | `dashboard/src/api/request.ts` |
313327
| Internationalization (backend) | `src/octop/i18n/`, `infra/utils/locale.py`, `api/routers/i18n.py` |
314328
| Internationalization (dashboard) | `dashboard/src/locales/`, `dashboard/src/i18n.ts`, `dashboard/src/utils/apiError.ts` |
329+
| Server timezone (config.json) | `default_timezone` in `config.py`; `GET /api/settings/timezone`; `dashboard/src/hooks/useServerTimezone.ts`; `dashboard/src/utils/formatMessageTime.ts` |
315330
| Test layout & shared helpers | `tests/support/` (`fakes`, `auth`, `http`, `scenarios`, `app`), `tests/integration/conftest.py`, `tests/unit/{db,cron,gateway,agents,api,cli}/` |
316331
| What is a Thread? | `infra/gateway/threads.py`, `infra/db/repos/threads.py` |
317332
| Workspace backend resolution | `infra/backend/resolver.py`, `infra/backend/adapter.py` |

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<p align="center">
2-
<img src="snapshots/banner.png" alt="Octop Banner" width="600" />
2+
<img src="docs/assets/readme-banner.png" alt="Octop Banner" width="600" />
33
</p>
44

55
<p align="center">
@@ -326,7 +326,7 @@ Full reference: **[docs/cli.md](docs/cli.md)**.
326326
After `octop run`, open **http://127.0.0.1:8088**.
327327

328328
<p align="center">
329-
<img src="snapshots/chat.png" alt="Octop Web Dashboard" width="800" />
329+
<img src="docs/assets/readme-chat.png" alt="Octop Web Dashboard" width="800" />
330330
</p>
331331

332332
- **Chat** — real-time conversation with agents

README_CN.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<p align="center">
2-
<img src="snapshots/banner.png" alt="Octop Banner" width="600" />
2+
<img src="docs/assets/readme-banner-zh.png" alt="Octop Banner" width="600" />
33
</p>
44

55
<p align="center">
@@ -332,7 +332,7 @@ OpenAI 兼容 API、DashScope(千问)、Ollama 等预设 — 在控制台或
332332
`octop run` 启动后访问 **http://127.0.0.1:8088**。
333333

334334
<p align="center">
335-
<img src="snapshots/chat_zh.png" alt="Octop Web 控制台" width="800" />
335+
<img src="docs/assets/readme-chat-zh.png" alt="Octop Web 控制台" width="800" />
336336
</p>
337337

338338
- **对话** — 与 Agent 实时聊天

dashboard/src/api/modules/backup.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export interface BackupFileItem {
44
name: string;
55
size: number;
66
modified_at: string;
7+
created_at: string;
78
}
89

910
export interface BackupListResponse {
@@ -48,10 +49,11 @@ export const backupApi = {
4849
/** Upload archive into backups dir (does not restore). */
4950
uploadBackup: (
5051
file: File,
52+
onProgress?: (percent: number) => void,
5153
): Promise<{ ok: boolean; item: BackupFileItem }> => {
5254
const formData = new FormData();
5355
formData.append("file", file);
54-
return requestUpload("/admin/backup/import", formData);
56+
return requestUpload("/admin/backup/import", formData, {}, onProgress);
5557
},
5658

5759
/** Ephemeral download without saving to backups dir. */
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { request } from "../request";
2+
3+
export interface OctopTimezoneSettings {
4+
timezone: string;
5+
}
6+
7+
export const octopSettingsApi = {
8+
timezone: () => request<OctopTimezoneSettings>("/settings/timezone"),
9+
};

dashboard/src/api/request.ts

Lines changed: 96 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -288,42 +288,116 @@ export async function requestBlob(
288288
return response.blob();
289289
}
290290

291+
export type UploadProgressHandler = (percent: number) => void;
292+
291293
/**
292294
* Upload a FormData payload (no explicit Content-Type — browser handles boundary).
295+
* Uses XMLHttpRequest so callers can report upload progress.
293296
*/
294297
export async function requestUpload<T = unknown>(
295298
path: string,
296299
body: FormData,
297300
options: RequestInit = {},
301+
onProgress?: UploadProgressHandler,
298302
): Promise<T> {
299303
const url = getApiUrl(path);
300304
const headers = buildAuthHeaders(path);
305+
const method = options.method ?? "POST";
301306

302-
const response = await fetch(url, {
303-
method: "POST",
304-
...options,
305-
headers: { ...headers, ...(options.headers as Record<string, string>) },
306-
body,
307-
});
307+
return new Promise<T>((resolve, reject) => {
308+
const xhr = new XMLHttpRequest();
309+
xhr.open(method, url);
308310

309-
if (await check503ForSetupRequired(path, response)) {
310-
throw new Error("Setup required — redirecting to /setup");
311-
}
311+
for (const [key, value] of Object.entries(headers)) {
312+
xhr.setRequestHeader(key, value);
313+
}
314+
if (options.headers) {
315+
const extraEntries =
316+
options.headers instanceof Headers
317+
? Array.from(options.headers.entries())
318+
: Array.isArray(options.headers)
319+
? options.headers
320+
: Object.entries(options.headers);
321+
for (const [k, v] of extraEntries) {
322+
xhr.setRequestHeader(k, String(v));
323+
}
324+
}
312325

313-
await throwIfUnauthorized(path, response);
314-
applyRenewedAccessToken(response);
326+
if (onProgress) {
327+
xhr.upload.onprogress = (event) => {
328+
if (event.lengthComputable && event.total > 0) {
329+
onProgress(Math.round((event.loaded / event.total) * 100));
330+
}
331+
};
332+
}
315333

316-
if (!response.ok) {
317-
const text = await response.text().catch(() => "");
318-
let detail = `Upload failed: ${response.status}`;
319-
try {
320-
const json = JSON.parse(text);
321-
if (json.detail) detail = json.detail;
322-
} catch {
323-
if (text) detail = text;
334+
if (options.signal) {
335+
if (options.signal.aborted) {
336+
reject(new DOMException("The operation was aborted.", "AbortError"));
337+
return;
338+
}
339+
options.signal.addEventListener(
340+
"abort",
341+
() => {
342+
xhr.abort();
343+
},
344+
{ once: true },
345+
);
324346
}
325-
throw new Error(detail);
326-
}
327347

328-
return (await response.json()) as T;
348+
xhr.onload = () => {
349+
void (async () => {
350+
const status = xhr.status;
351+
const responseText = xhr.responseText;
352+
const responseHeaders = new Headers();
353+
const renewed = xhr.getResponseHeader(ACCESS_TOKEN_RESPONSE_HEADER);
354+
if (renewed) {
355+
responseHeaders.set(ACCESS_TOKEN_RESPONSE_HEADER, renewed);
356+
}
357+
const response = new Response(responseText, {
358+
status,
359+
headers: responseHeaders,
360+
});
361+
362+
if (await check503ForSetupRequired(path, response)) {
363+
reject(new Error("Setup required — redirecting to /setup"));
364+
return;
365+
}
366+
367+
try {
368+
await throwIfUnauthorized(path, response);
369+
} catch (err) {
370+
reject(err);
371+
return;
372+
}
373+
374+
applyRenewedAccessToken(response);
375+
376+
if (!response.ok) {
377+
const text = responseText;
378+
let detail = `Upload failed: ${status}`;
379+
try {
380+
const json = JSON.parse(text) as { detail?: string };
381+
if (json.detail) detail = json.detail;
382+
} catch {
383+
if (text) detail = text;
384+
}
385+
reject(new Error(detail));
386+
return;
387+
}
388+
389+
try {
390+
resolve((await response.json()) as T);
391+
} catch {
392+
reject(new Error("Upload failed: invalid JSON response"));
393+
}
394+
})();
395+
};
396+
397+
xhr.onerror = () => reject(new Error("Network error"));
398+
xhr.onabort = () =>
399+
reject(new DOMException("The operation was aborted.", "AbortError"));
400+
401+
xhr.send(body);
402+
});
329403
}

dashboard/src/hooks/useServerTimezone.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { useEffect, useState } from "react";
2-
import { octopCronApi } from "../api/modules/cronjob";
2+
import { octopSettingsApi } from "../api/modules/settings";
33

44
let cachedTimezone: string | null = null;
55
let inflight: Promise<string> | null = null;
66

77
async function fetchServerTimezone(): Promise<string> {
88
if (cachedTimezone) return cachedTimezone;
99
if (!inflight) {
10-
inflight = octopCronApi
11-
.settings()
10+
inflight = octopSettingsApi
11+
.timezone()
1212
.then((settings) => {
1313
cachedTimezone = settings.timezone?.trim() || "UTC";
1414
return cachedTimezone;
@@ -24,7 +24,7 @@ async function fetchServerTimezone(): Promise<string> {
2424
return inflight;
2525
}
2626

27-
/** Server timezone from config.json `cron_timezone` (via GET /api/cron/settings). */
27+
/** Server timezone from config.json `default_timezone` (via GET /api/settings/timezone). */
2828
export function useServerTimezone(): string {
2929
const [timezone, setTimezone] = useState(cachedTimezone ?? "UTC");
3030

dashboard/src/layouts/Sidebar.tsx

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,6 @@ function buildNavGroups(role: "admin" | "user" | null): NavGroup[] {
137137
path: "/experts",
138138
icon: <GraduationCap size={iconSize} strokeWidth={iconStroke} />,
139139
labelKey: "nav.experts",
140-
badge: "new",
141140
},
142141
{
143142
key: "tasks",
@@ -150,7 +149,6 @@ function buildNavGroups(role: "admin" | "user" | null): NavGroup[] {
150149
path: "/connectors",
151150
icon: <Link2 size={iconSize} strokeWidth={iconStroke} />,
152151
labelKey: "nav.connectors",
153-
badge: "new",
154152
},
155153
{
156154
key: "token-usage",
@@ -181,7 +179,6 @@ function buildNavGroups(role: "admin" | "user" | null): NavGroup[] {
181179
path: "/subagents",
182180
icon: <Bot size={iconSize} strokeWidth={iconStroke} />,
183181
labelKey: "nav.subagents",
184-
badge: "new",
185182
},
186183
{
187184
key: "terminal",
@@ -200,14 +197,12 @@ function buildNavGroups(role: "admin" | "user" | null): NavGroup[] {
200197
path: "/remote-desktop",
201198
icon: <Monitor size={iconSize} strokeWidth={iconStroke} />,
202199
labelKey: "nav.remoteDesktop",
203-
badge: "new",
204200
},
205201
{
206202
key: "acp",
207203
path: "/acp",
208204
icon: <Share2 size={iconSize} strokeWidth={iconStroke} />,
209205
labelKey: "nav.acp",
210-
badge: "new",
211206
},
212207
{
213208
key: "mbti",
@@ -246,7 +241,6 @@ function buildNavGroups(role: "admin" | "user" | null): NavGroup[] {
246241
path: "/admin/backend",
247242
icon: <FolderOpen size={iconSize} strokeWidth={iconStroke} />,
248243
labelKey: "nav.adminStorage",
249-
badge: "new",
250244
},
251245
{
252246
key: "admin-audit",

dashboard/src/locales/en.json

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -751,6 +751,7 @@
751751
"emptyList": "No backup files yet",
752752
"colName": "Filename",
753753
"colSize": "Size",
754+
"colCreated": "Created",
754755
"colModified": "Modified",
755756
"colActions": "Actions",
756757
"restoreAction": "Restore",
@@ -763,15 +764,17 @@
763764
"exportSuccess": "Backup download started",
764765
"exportFailed": "Download failed",
765766
"importTitle": "Import backup",
766-
"importDesc": "Restore from a previously exported .tar.gz archive. Prefer a maintenance window; restart the server after restore.",
767+
"importDesc": "Restore from a previously exported .tar.gz archive. Prefer a maintenance window.",
767768
"importButton": "Choose backup file",
768-
"importWarning": "Restore overwrites the current database and local workspaces. This cannot be undone.",
769+
"uploading": "Uploading… {{percent}}%",
770+
"restoring": "Restoring…",
771+
"importWarning": "Restore overwrites the current database and local workspaces, then hot-reloads models and experts. If you also restore config/env, restart the service manually for those to fully apply. This cannot be undone.",
769772
"importConfirmTitle": "Confirm restore",
770-
"importConfirmBody": "Restore from “{{name}}”? Existing data will be overwritten.",
773+
"importConfirmBody": "Restore from “{{name}}”? Existing data will be overwritten. Models and experts are hot-reloaded afterward (no service restart). If you restore config/env, restart manually for those settings to take full effect.",
771774
"importConfirmOk": "Restore",
772775
"importSuccess": "Restore complete ({{agents}} agents, {{files}} workspace files)",
773776
"importFailed": "Failed to restore backup",
774-
"restoreConfig": "Also restore config.json and env"
777+
"restoreConfig": "Also restore config.json and env (requires a manual service restart to apply)"
775778
},
776779
"skills": {
777780
"title": "Skills",
@@ -964,7 +967,7 @@
964967
"cronTooltip": "Standard 5-field cron expression: minute hour day month weekday.",
965968
"cronExamples": "Examples: 0 9 * * * (daily at 9am) | */30 * * * * (every 30min) | 0 9 * * 1 (Mon 9am)",
966969
"timezone": "Timezone",
967-
"timezoneTooltip": "Server-wide scheduler timezone from config.json (cron_timezone) or OCTOP_CRON_TIMEZONE. Shared by all cron jobs.",
970+
"timezoneTooltip": "Server-wide default timezone from config.json (default_timezone) or OCTOP_DEFAULT_TIMEZONE. Used for scheduling and dashboard timestamps.",
968971
"sectionTask": "Task Content",
969972
"sectionTaskDesc": "Define what to do when triggered.",
970973
"taskType": "Task Type",

dashboard/src/locales/zh.json

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,7 @@
748748
"emptyList": "暂无备份文件",
749749
"colName": "文件名",
750750
"colSize": "大小",
751+
"colCreated": "创建时间",
751752
"colModified": "修改时间",
752753
"colActions": "操作",
753754
"restoreAction": "恢复",
@@ -760,15 +761,17 @@
760761
"exportSuccess": "备份已开始下载",
761762
"exportFailed": "下载失败",
762763
"importTitle": "导入备份",
763-
"importDesc": "从先前导出的 .tar.gz 归档恢复系统。建议在维护窗口操作,恢复后重启服务",
764+
"importDesc": "从先前导出的 .tar.gz 归档恢复系统。建议在维护窗口操作。",
764765
"importButton": "选择备份文件",
765-
"importWarning": "恢复将覆盖当前数据库与本地工作区,操作不可撤销。",
766+
"uploading": "正在上传… {{percent}}%",
767+
"restoring": "正在恢复…",
768+
"importWarning": "恢复将覆盖当前数据库与本地工作区,并热加载模型与专家。若同时恢复了 config/env,完整生效仍需手动重启服务。此操作不可撤销。",
766769
"importConfirmTitle": "确认恢复备份",
767-
"importConfirmBody": "确定从「{{name}}」恢复?这将覆盖现有数据。",
770+
"importConfirmBody": "确定从「{{name}}」恢复?这将覆盖现有数据。恢复后会热加载模型与专家,无需重启服务;若勾选恢复配置,config/env 需重启后才完全生效。",
768771
"importConfirmOk": "恢复",
769772
"importSuccess": "恢复完成({{agents}} 个 Agent,{{files}} 个工作区文件)",
770773
"importFailed": "恢复备份失败",
771-
"restoreConfig": "同时恢复 config.json 与 env"
774+
"restoreConfig": "同时恢复 config.json 与 env(需手动重启服务后生效)"
772775
},
773776
"skills": {
774777
"title": "技能",
@@ -960,7 +963,7 @@
960963
"cronTooltip": "标准 5 位 Cron 表达式,格式:分 时 日 月 周。",
961964
"cronExamples": "示例:0 9 * * *(每天9点)| */30 * * * *(每30分钟)| 0 9 * * 1(每周一9点)",
962965
"timezone": "时区",
963-
"timezoneTooltip": "服务端调度时区,在 config.json 的 cron_timezone 或环境变量 OCTOP_CRON_TIMEZONE 中配置,所有定时任务共用",
966+
"timezoneTooltip": "服务端默认时区,在 config.json 的 default_timezone 或环境变量 OCTOP_DEFAULT_TIMEZONE 中配置,用于定时任务与界面时间展示",
964967
"sectionTask": "任务内容",
965968
"sectionTaskDesc": "定义触发时要做什么。",
966969
"taskType": "任务类型",

0 commit comments

Comments
 (0)