@zq-silk/yui 2.14.0 → 2.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config/configCatalog.js +1 -1
- package/dist/controlPlaneCli.js +1 -1
- package/dist/job/projectSync.js +3 -3
- package/dist/storage/sqliteSchema.js +12 -0
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/upgrade/projectSyncClockMigration.js +30 -0
- package/dist/web/assets/assetManifest.js +23 -1
- package/dist/web/assets/client/app/catalog.js +40 -14
- package/dist/web/assets/client/app/login.js +156 -0
- package/dist/web/assets/client/app/main.js +33 -9
- package/dist/web/assets/client/app/manage.js +18 -0
- package/dist/web/assets/client/app/selection.js +4 -1
- package/dist/web/assets/client/app/taskView.js +22 -5
- package/dist/web/assets/client/i18n/en.js +28 -4
- package/dist/web/assets/client/i18n/zh-CN.js +28 -4
- package/dist/web/assets/client/index.js +8 -0
- package/dist/web/assets/client/layout/manageShell.js +30 -8
- package/dist/web/assets/client/layout/shortcuts.js +7 -3
- package/dist/web/assets/client/layout/sidebarNav.js +147 -0
- package/dist/web/assets/client/layout/sizing.js +5 -2
- package/dist/web/assets/client/layout/workspace.js +120 -21
- package/dist/web/assets/client/lib/api.js +5 -0
- package/dist/web/assets/client/lib/motion.js +59 -0
- package/dist/web/assets/client/lib/prefs.js +29 -2
- package/dist/web/assets/client/lib/session.js +21 -0
- package/dist/web/assets/client/lib/theme.js +11 -4
- package/dist/web/assets/client/ui/primitives.js +27 -0
- package/dist/web/assets/client/views/dock/terminal.js +4 -1
- package/dist/web/assets/client/views/overview.js +8 -5
- package/dist/web/assets/client/views/task/page.js +10 -10
- package/dist/web/assets/shared/brand.js +22 -0
- package/dist/web/assets/shared/geometry.js +7 -1
- package/dist/web/assets/shared/icons.js +5 -0
- package/dist/web/assets/shared/motion.js +74 -0
- package/dist/web/assets/shell/appRail.js +14 -0
- package/dist/web/assets/shell/dialogs.js +1 -0
- package/dist/web/assets/shell/dock.js +19 -6
- package/dist/web/assets/shell/index.js +6 -2
- package/dist/web/assets/shell/login.js +57 -0
- package/dist/web/assets/shell/manage.js +32 -20
- package/dist/web/assets/shell/settingsContent.js +1 -1
- package/dist/web/assets/shell/sidebar.js +16 -23
- package/dist/web/assets/styles/base.js +1 -1
- package/dist/web/assets/styles/index.js +4 -0
- package/dist/web/assets/styles/layout/responsive.js +63 -19
- package/dist/web/assets/styles/layout/workspace.js +53 -16
- package/dist/web/assets/styles/motion.js +51 -0
- package/dist/web/assets/styles/tokens.js +5 -3
- package/dist/web/assets/styles/ui/controls.js +1 -1
- package/dist/web/assets/styles/ui/forms.js +1 -1
- package/dist/web/assets/styles/ui/surfaces.js +1 -2
- package/dist/web/assets/styles/views/conversation.js +1 -1
- package/dist/web/assets/styles/views/dock.js +22 -2
- package/dist/web/assets/styles/views/login.js +45 -0
- package/dist/web/assets/styles/views/manage.js +25 -42
- package/dist/web/assets/styles/views/page.js +2 -3
- package/dist/web/assets/styles/views/records.js +1 -1
- package/dist/web/assets/styles/views/settings.js +1 -1
- package/dist/web/assets/styles/views/sidebar.js +25 -20
- package/dist/web/assets/styles/views/task.js +9 -15
- package/dist/web/assets/styles/views/terminal.js +1 -1
- package/dist/web/webAccess.js +129 -9
- package/dist/web/webServer.js +170 -14
- package/docs/release-workflow.md +67 -0
- package/docs/release-workflow.zh-CN.md +49 -0
- package/docs/storage-baseline.md +7 -1
- package/docs/storage-baseline.zh-CN.md +6 -1
- package/docs/testing/verification-levels.md +77 -11
- package/docs/testing/verification-levels.zh-CN.md +63 -9
- package/package.json +1 -1
|
@@ -8,7 +8,7 @@ export const CONFIG_DOMAINS = ["system", "runtime", "workflow", "resources", "to
|
|
|
8
8
|
export const CONFIG_DEFINITIONS = Object.freeze([
|
|
9
9
|
{ key: "default-agent", domain: "system", property: "defaultAgent", label: "Default Agent", summary: "Configured Agent used when a Task or Role does not select one explicitly.", takesEffect: "Future Role and Task defaults; existing bindings are unchanged." },
|
|
10
10
|
{ key: "default-workspace", domain: "system", property: "defaultWorkspace", label: "Default workspace", summary: "Absolute workspace root outside YUI_HOME used when no workspace is selected.", takesEffect: "Future workspace selection; existing Roles and worktrees are unchanged." },
|
|
11
|
-
{ key: "time-zone", domain: "system", property: "timeZone", label: "Time zone", summary: "IANA timezone for human-facing timestamps (default: Asia/Shanghai).", takesEffect: "The next human-readable timestamp rendering; durable
|
|
11
|
+
{ key: "time-zone", domain: "system", property: "timeZone", label: "Time zone", summary: "IANA timezone for human-facing timestamps and new default Project sync plans (default: Asia/Shanghai).", takesEffect: "The next human-readable timestamp rendering and newly installed Project sync plans; existing Schedule rules stay unchanged and durable timestamps remain UTC." },
|
|
12
12
|
{ key: "reconciliation-interval-seconds", domain: "runtime", property: "reconciliationIntervalSeconds", label: "Reconciliation interval", summary: "Recovery reconciliation interval, 5-300 seconds (default: 120).", takesEffect: "The running Controller is refreshed after the value is saved." },
|
|
13
13
|
{ key: "controller-task-concurrency", domain: "runtime", property: "controllerTaskConcurrency", label: "Controller Task concurrency", summary: "Maximum Tasks reconciled concurrently, 1-32 (default: 4).", takesEffect: "After the Controller restarts." },
|
|
14
14
|
{ key: "runtime-health", domain: "runtime", property: "runtimeHealth", label: "Runtime health thresholds", summary: "Quiet, checkpoint-overdue, and read-only diagnostic thresholds in seconds; values must be strictly increasing (defaults: 300/900/1800).", takesEffect: "CLI and Web projections immediately; scheduler diagnostics after the Controller restarts." },
|
package/dist/controlPlaneCli.js
CHANGED
|
@@ -689,7 +689,7 @@ export async function main() {
|
|
|
689
689
|
const describe = (status) => status
|
|
690
690
|
? `Web listener: ${status.state}\n`
|
|
691
691
|
+ (status.alreadyRunning ? "Existing listener reused; credentials were not reloaded.\n" : "")
|
|
692
|
-
+ (status.authentication === "
|
|
692
|
+
+ (status.authentication === "password" ? "Browser sign-in: open the URL and enter the password from your private credential file on the Yui sign-in page.\n" : "")
|
|
693
693
|
+ (status.urls ?? []).map(url => `Yui web control room: ${url}\n`).join("")
|
|
694
694
|
+ (status.warnings ?? []).map(warning => `Warning: ${warning}\n`).join("")
|
|
695
695
|
: "Web is not running. Start it explicitly; Controller recovery does not restore listeners.\n";
|
package/dist/job/projectSync.js
CHANGED
|
@@ -7,9 +7,9 @@ import { requireRefreshProject, projectRefreshTargetDigest } from "../repository
|
|
|
7
7
|
import { createJobDefinition, reviseJobDefinition, validateJobDefinition } from "./jobDefinition.js";
|
|
8
8
|
import { nextScheduleDue, validateScheduleRule } from "../schedule/schedule.js";
|
|
9
9
|
import { scheduleServiceBinding } from "./scheduleService.js";
|
|
10
|
+
import { resolveTimeZone } from "../config/timeZone.js";
|
|
10
11
|
const scope = { kind: "global" };
|
|
11
12
|
const unavailable = "Project sync unavailable: ";
|
|
12
|
-
export const PROJECT_SYNC_INTERVAL_MS = 6 * 60 * 60_000;
|
|
13
13
|
/** Called under the maintenance lock immediately before Git, not a timer probe. */
|
|
14
14
|
export function requireProjectSyncCheckout(store, job) {
|
|
15
15
|
requireProjectSyncTarget(store, job);
|
|
@@ -86,8 +86,8 @@ export function reconcileProjectSync(store, now, onError = () => { }) {
|
|
|
86
86
|
reason = unavailable + (error instanceof Error ? error.message : String(error));
|
|
87
87
|
}
|
|
88
88
|
if (!plan) {
|
|
89
|
-
const rule = { kind: "
|
|
90
|
-
|
|
89
|
+
const rule = { kind: "cron", expression: "0 */6 * * *",
|
|
90
|
+
timezone: resolveTimeZone(tx.getConfig().timeZone) };
|
|
91
91
|
const grantId = randomUUID();
|
|
92
92
|
plan = { schemaVersion: 1, id, scope, jobId: id, target: { kind: "existing-job" }, rule,
|
|
93
93
|
builtin: { projectId, enabled: true }, revision: 1, generation: 1,
|
|
@@ -10,6 +10,7 @@ import { GLOBAL_SESSION_TABLE_SQL, SESSION_HISTORY_UPGRADE_SQL } from "./session
|
|
|
10
10
|
import { migrateJobDefinitions } from "./upgrade/jobDefinitionMigration.js";
|
|
11
11
|
import { migrateJobBusinessResults } from "./upgrade/jobBusinessResultMigration.js";
|
|
12
12
|
import { migrateScheduleDispatch } from "./upgrade/scheduleDispatchMigration.js";
|
|
13
|
+
import { migrateProjectSyncClock } from "./upgrade/projectSyncClockMigration.js";
|
|
13
14
|
import { CURRENT_STORAGE_VERSION, MIN_SUPPORTED_STORAGE_VERSION, STORAGE_FORMAT, isMinorStorageUpgrade, isStorageVersion, storageVersionParts } from "./storageVersions.js";
|
|
14
15
|
export { BASELINE_SCHEMA_SQL };
|
|
15
16
|
export const SQLITE_SCHEMA_TABLES = [...BASELINE_SCHEMA_TABLES, "job_definitions", "global_job_runs", "schedules", "agent_job_runs"];
|
|
@@ -233,6 +234,15 @@ const MINOR_UPGRADES = Object.freeze([{
|
|
|
233
234
|
// Existing records and immutable artifacts remain byte-for-byte unchanged.
|
|
234
235
|
// Declares Agent definitions/Runs and optional Job ownership of a non-Task Role.
|
|
235
236
|
sql: AGENT_JOB_SCHEMA_SQL
|
|
237
|
+
}, {
|
|
238
|
+
fromVersion: "1.20",
|
|
239
|
+
toVersion: "1.21",
|
|
240
|
+
name: "project-sync-clock",
|
|
241
|
+
introducedIn: "next",
|
|
242
|
+
sourceChecksum: CURRENT_SCHEMA_CHECKSUM,
|
|
243
|
+
targetChecksum: CURRENT_SCHEMA_CHECKSUM,
|
|
244
|
+
sql: "",
|
|
245
|
+
dataMigration: "project-sync-clock"
|
|
236
246
|
}]);
|
|
237
247
|
export function storageMinorUpgradePlan(from) {
|
|
238
248
|
if (from === CURRENT_STORAGE_VERSION)
|
|
@@ -363,6 +373,8 @@ export function applySqliteMinorUpgrades(db, home, createdRoots = []) {
|
|
|
363
373
|
migrateJobBusinessResults(db);
|
|
364
374
|
if (step.dataMigration === "schedule-dispatch")
|
|
365
375
|
migrateScheduleDispatch(db);
|
|
376
|
+
if (step.dataMigration === "project-sync-clock")
|
|
377
|
+
migrateProjectSyncClock(db);
|
|
366
378
|
const { major, minor } = storageVersionParts(step.toVersion);
|
|
367
379
|
db.prepare("UPDATE storage_schema SET major=?,minor=?,checksum=? WHERE id=1")
|
|
368
380
|
.run(major, minor, step.targetChecksum);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { resolveTimeZone } from "../../config/timeZone.js";
|
|
2
|
+
import { nextScheduleDue, validateSchedule } from "../../schedule/schedule.js";
|
|
3
|
+
/** 1.20 had no rule-edit provenance. Only revision 1 proves no user mutation.
|
|
4
|
+
* Higher revisions may be dispatch, recovery OR an equal-valued user edit;
|
|
5
|
+
* preserve them instead of guessing from interval values or generation.
|
|
6
|
+
*/
|
|
7
|
+
export function migrateProjectSyncClock(db) {
|
|
8
|
+
const now = new Date();
|
|
9
|
+
const rows = db.prepare("SELECT rowid,scope_key,schedule_id,payload FROM schedules").all();
|
|
10
|
+
for (const row of rows) {
|
|
11
|
+
const old = JSON.parse(row.payload);
|
|
12
|
+
if (!old.builtin || old.revision !== 1)
|
|
13
|
+
continue;
|
|
14
|
+
validateSchedule(old);
|
|
15
|
+
// Confirm the declared install shape, in addition to the mutation evidence.
|
|
16
|
+
if (old.generation !== 1 || old.rule.kind !== "interval" || old.rule.everyMs !== 21_600_000
|
|
17
|
+
|| Date.parse(old.rule.anchor) !== Date.parse(old.createdAt) + 21_600_000)
|
|
18
|
+
continue;
|
|
19
|
+
const config = db.prepare("SELECT payload FROM config WHERE id=1").get();
|
|
20
|
+
const rule = { kind: "cron", expression: "0 */6 * * *",
|
|
21
|
+
timezone: resolveTimeZone(config ? JSON.parse(config.payload).timeZone : undefined) };
|
|
22
|
+
const after = new Date(Math.max(now.getTime(), Date.parse(old.processedThrough ?? "") || 0));
|
|
23
|
+
const next = validateSchedule({ ...old, rule, revision: old.revision + 1, generation: old.generation + 1,
|
|
24
|
+
processedThrough: after.toISOString(), nextDue: nextScheduleDue(rule, after), updatedAt: now.toISOString() });
|
|
25
|
+
db.prepare("INSERT INTO storage_migration_archive(migration_version,family,record_key,payload) VALUES(?,?,?,?)")
|
|
26
|
+
.run(21, "schedules", `${row.scope_key}/${row.schedule_id}`, row.payload);
|
|
27
|
+
db.prepare("UPDATE schedules SET payload=?,next_due=? WHERE rowid=?")
|
|
28
|
+
.run(JSON.stringify(next), next.nextDue, row.rowid);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -8,6 +8,7 @@ import { APP_SCRIPT, CLIENT_MODULES } from "./client/index.js";
|
|
|
8
8
|
import { THEME_BOOTSTRAP_SCRIPT } from "./client/lib/theme.js";
|
|
9
9
|
import { FONT_WOFF2_BASE64 } from "./fontData.js";
|
|
10
10
|
import { DASHBOARD_HTML } from "./shell/index.js";
|
|
11
|
+
import { LOGIN_HTML } from "./shell/login.js";
|
|
11
12
|
import { STYLESHEETS } from "./styles/index.js";
|
|
12
13
|
const require = createRequire(import.meta.url);
|
|
13
14
|
const CSS = "text/css; charset=utf-8";
|
|
@@ -22,7 +23,28 @@ export const WEB_ASSETS = Object.freeze({
|
|
|
22
23
|
"/assets/vendor/addon-fit.mjs": vendorAsset("@xterm/addon-fit/lib/addon-fit.mjs", JS),
|
|
23
24
|
"/assets/vendor/xterm.css": vendorAsset("@xterm/xterm/css/xterm.css", CSS)
|
|
24
25
|
});
|
|
25
|
-
export { DASHBOARD_HTML };
|
|
26
|
+
export { DASHBOARD_HTML, LOGIN_HTML };
|
|
27
|
+
// The sign-in document's static closure (its stylesheets, fonts, theme script
|
|
28
|
+
// and module import graph). Only these assets are served without a session;
|
|
29
|
+
// every other asset, like every document and API, requires sign-in.
|
|
30
|
+
const LOGIN_ASSETS = (() => {
|
|
31
|
+
const found = new Set();
|
|
32
|
+
const visit = (body) => {
|
|
33
|
+
for (const [path] of body.matchAll(/\/assets\/[\w./-]+/g)) {
|
|
34
|
+
const asset = WEB_ASSETS[path];
|
|
35
|
+
if (!asset || found.has(path))
|
|
36
|
+
continue;
|
|
37
|
+
found.add(path);
|
|
38
|
+
if (asset.encoding !== "base64")
|
|
39
|
+
visit(asset.body);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
visit(LOGIN_HTML);
|
|
43
|
+
return found;
|
|
44
|
+
})();
|
|
45
|
+
export function isLoginAsset(pathname) {
|
|
46
|
+
return LOGIN_ASSETS.has(pathname);
|
|
47
|
+
}
|
|
26
48
|
// One content revision covers the complete import graph: changing a dependency
|
|
27
49
|
// invalidates its importers too. Only these static assets may be cached.
|
|
28
50
|
export const WEB_ASSET_VERSION = createHash("sha256")
|
|
@@ -51,8 +51,21 @@ async function refresh(deps, progress, options) {
|
|
|
51
51
|
const query = catalogQuery(state);
|
|
52
52
|
const dashboard = await deps.api.dashboard(query);
|
|
53
53
|
if (request !== progress.request) return;
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
// An unchanged quiet poll leaves the list and overview alone: rebuilding
|
|
55
|
+
// them every few seconds would interrupt scrolling and animations. They
|
|
56
|
+
// still redraw once a minute so relative times stay true.
|
|
57
|
+
const signature = query + "|" + JSON.stringify(dashboard);
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
if (quiet && signature === progress.signature && now - progress.drawnAt < 60000) {
|
|
60
|
+
state.generatedAt = new Date().toISOString();
|
|
61
|
+
el.sync.dataset.state = "ok";
|
|
62
|
+
el.lastSync.textContent = formatClock(state.generatedAt, deps.locale());
|
|
63
|
+
} else {
|
|
64
|
+
progress.signature = signature;
|
|
65
|
+
progress.drawnAt = now;
|
|
66
|
+
applyDashboard(state, dashboard, query);
|
|
67
|
+
showDashboard(deps, progress, previousInputs);
|
|
68
|
+
}
|
|
56
69
|
// A selected Task may live on another page or outside current filters;
|
|
57
70
|
// only its own read can establish that it no longer exists.
|
|
58
71
|
if (state.selected) {
|
|
@@ -98,18 +111,10 @@ function renderSidebar(deps, progress) {
|
|
|
98
111
|
reset(deps, progress);
|
|
99
112
|
});
|
|
100
113
|
el.catalogCount.textContent = fill(t("catalog.count"), { shown: state.tasks.length, total: state.catalogTotal });
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
state.source = source.value; state.attentionFilter = null; state.query = ""; el.search.value = "";
|
|
106
|
-
syncUrl(state, false); reset(deps, progress);
|
|
107
|
-
});
|
|
108
|
-
el.filters.append(source);
|
|
109
|
-
}
|
|
110
|
-
source.replaceChildren(...["ordinary", "job", "all"].map(value =>
|
|
111
|
-
h("option", { value }, t("jobs.category." + value))));
|
|
112
|
-
source.value = state.query.trim() || state.attentionFilter ? "all" : state.source || "ordinary";
|
|
114
|
+
renderSource(el.source, state, t, function (value) {
|
|
115
|
+
state.source = value; state.attentionFilter = null; state.query = ""; el.search.value = "";
|
|
116
|
+
syncUrl(state, false); reset(deps, progress);
|
|
117
|
+
});
|
|
113
118
|
el.catalogReset.hidden = !state.catalogCursor;
|
|
114
119
|
el.catalogNext.hidden = !state.nextCursor && !state.catalogCursor;
|
|
115
120
|
el.catalogAttentionReset.hidden = !state.attentionFilter;
|
|
@@ -117,6 +122,27 @@ function renderSidebar(deps, progress) {
|
|
|
117
122
|
el.lastSync.textContent = state.generatedAt ? formatClock(state.generatedAt, deps.locale()) : "—";
|
|
118
123
|
}
|
|
119
124
|
|
|
125
|
+
// Task category: search and attention are cross-category, so they show "all";
|
|
126
|
+
// picking a category clears them, as the server filter then applies again.
|
|
127
|
+
function renderSource(container, state, t, onPick) {
|
|
128
|
+
const current = state.query.trim() || state.attentionFilter ? "all" : state.source || "ordinary";
|
|
129
|
+
// Bound once; the handler reads the live state, not this render's snapshot.
|
|
130
|
+
if (!container.childElementCount) {
|
|
131
|
+
["ordinary", "job", "all"].forEach(function (value) {
|
|
132
|
+
container.append(h("button.seg-btn", { type: "button", role: "radio", dataset: { source: value },
|
|
133
|
+
onclick: function () {
|
|
134
|
+
if (value !== (state.source || "ordinary") || state.query.trim() || state.attentionFilter) onPick(value);
|
|
135
|
+
} }));
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
container.querySelectorAll("[data-source]").forEach(function (button) {
|
|
139
|
+
const value = button.dataset.source;
|
|
140
|
+
button.textContent = t("jobs.categoryShort." + value);
|
|
141
|
+
button.title = t("jobs.category." + value);
|
|
142
|
+
button.setAttribute("aria-checked", String(value === current));
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
120
146
|
// Redraw the list in place, keeping its scroll position.
|
|
121
147
|
function renderTaskList(deps) {
|
|
122
148
|
const scroll = deps.el.tasks.scrollTop;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
export const LOGIN_SCRIPT = String.raw `
|
|
2
|
+
// Sign-in page controller. Posts the password once per submission as
|
|
3
|
+
// same-origin JSON; the server sets an HttpOnly session cookie and returns a
|
|
4
|
+
// validated same-origin destination. The password is never written to the
|
|
5
|
+
// URL, storage or console, and the field is cleared after a rejection.
|
|
6
|
+
import { createI18n } from "/assets/js/lib/i18n.js";
|
|
7
|
+
|
|
8
|
+
const form = document.querySelector("#login-form");
|
|
9
|
+
const password = document.querySelector("#login-password");
|
|
10
|
+
const reveal = document.querySelector("#login-reveal");
|
|
11
|
+
const submit = document.querySelector("#login-submit");
|
|
12
|
+
const submitLabel = document.querySelector("#login-submit-label");
|
|
13
|
+
const errorBox = document.querySelector("#login-error");
|
|
14
|
+
const notice = document.querySelector("#login-notice");
|
|
15
|
+
const transport = document.querySelector("#login-transport");
|
|
16
|
+
|
|
17
|
+
const i18n = createI18n(document.querySelector("#locale-select"));
|
|
18
|
+
const t = i18n.t;
|
|
19
|
+
const params = new URLSearchParams(window.location.search);
|
|
20
|
+
const next = params.get("next") || "/";
|
|
21
|
+
const reason = params.get("reason");
|
|
22
|
+
let busy = false;
|
|
23
|
+
let lockedUntil = 0;
|
|
24
|
+
let lockTimer = null;
|
|
25
|
+
let currentError = null;
|
|
26
|
+
|
|
27
|
+
function isLoopbackHost(host) {
|
|
28
|
+
return host === "localhost" || host === "[::1]" || /^127\./.test(host);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function showError(key, values) {
|
|
32
|
+
currentError = { key: key, values: values || {} };
|
|
33
|
+
let text = t(key);
|
|
34
|
+
Object.keys(currentError.values).forEach(function (name) { text = text.split("{" + name + "}").join(String(currentError.values[name])); });
|
|
35
|
+
errorBox.textContent = text;
|
|
36
|
+
errorBox.hidden = false;
|
|
37
|
+
password.setAttribute("aria-invalid", key === "login.error.network" || key === "login.error.origin" ? "false" : "true");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function clearError() {
|
|
41
|
+
currentError = null;
|
|
42
|
+
errorBox.hidden = true;
|
|
43
|
+
errorBox.textContent = "";
|
|
44
|
+
password.setAttribute("aria-invalid", "false");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function setBusy(value) {
|
|
48
|
+
busy = value;
|
|
49
|
+
form.setAttribute("aria-busy", value ? "true" : "false");
|
|
50
|
+
password.readOnly = value;
|
|
51
|
+
updateSubmit();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function updateSubmit() {
|
|
55
|
+
const locked = Date.now() < lockedUntil;
|
|
56
|
+
submit.disabled = busy || locked;
|
|
57
|
+
submitLabel.textContent = t(busy ? "login.submitting" : "login.submit");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function renderStatic() {
|
|
61
|
+
document.title = t("login.title");
|
|
62
|
+
if (reason === "expired" || reason === "signed-out") {
|
|
63
|
+
notice.className = "login-notice " + (reason === "expired" ? "tone-warn" : "tone-ok");
|
|
64
|
+
notice.textContent = t(reason === "expired" ? "login.notice.expired" : "login.notice.signedOut");
|
|
65
|
+
notice.hidden = false;
|
|
66
|
+
}
|
|
67
|
+
const revealed = reveal.getAttribute("aria-pressed") === "true";
|
|
68
|
+
reveal.setAttribute("aria-label", t(revealed ? "login.hide" : "login.show"));
|
|
69
|
+
reveal.title = t(revealed ? "login.hide" : "login.show");
|
|
70
|
+
if (currentError) showError(currentError.key, currentError.values);
|
|
71
|
+
updateSubmit();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function lockFor(seconds) {
|
|
75
|
+
lockedUntil = Date.now() + seconds * 1000;
|
|
76
|
+
window.clearInterval(lockTimer);
|
|
77
|
+
const tick = function () {
|
|
78
|
+
const remaining = Math.ceil((lockedUntil - Date.now()) / 1000);
|
|
79
|
+
if (remaining <= 0) {
|
|
80
|
+
window.clearInterval(lockTimer);
|
|
81
|
+
lockTimer = null;
|
|
82
|
+
clearError();
|
|
83
|
+
updateSubmit();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
showError("login.error.throttled", { seconds: remaining });
|
|
87
|
+
updateSubmit();
|
|
88
|
+
};
|
|
89
|
+
tick();
|
|
90
|
+
lockTimer = window.setInterval(tick, 1000);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
reveal.addEventListener("click", function () {
|
|
94
|
+
const revealed = reveal.getAttribute("aria-pressed") !== "true";
|
|
95
|
+
reveal.setAttribute("aria-pressed", revealed ? "true" : "false");
|
|
96
|
+
password.type = revealed ? "text" : "password";
|
|
97
|
+
renderStatic();
|
|
98
|
+
password.focus();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
password.addEventListener("input", function () {
|
|
102
|
+
if (currentError && currentError.key !== "login.error.throttled") clearError();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
form.addEventListener("submit", async function (event) {
|
|
106
|
+
event.preventDefault();
|
|
107
|
+
if (busy || Date.now() < lockedUntil) return;
|
|
108
|
+
if (!password.value) {
|
|
109
|
+
showError("login.error.empty");
|
|
110
|
+
password.focus();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
clearError();
|
|
114
|
+
setBusy(true);
|
|
115
|
+
let response;
|
|
116
|
+
let body = null;
|
|
117
|
+
try {
|
|
118
|
+
response = await fetch("/auth/login", {
|
|
119
|
+
method: "POST", credentials: "same-origin", cache: "no-store",
|
|
120
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
121
|
+
body: JSON.stringify({ password: password.value, next: next })
|
|
122
|
+
});
|
|
123
|
+
try { body = await response.json(); } catch {}
|
|
124
|
+
} catch {
|
|
125
|
+
setBusy(false);
|
|
126
|
+
showError("login.error.network");
|
|
127
|
+
password.focus();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (response.ok && body && typeof body.location === "string" && body.location.startsWith("/") && !body.location.startsWith("//")) {
|
|
131
|
+
// Keep the busy state while the browser navigates to the destination.
|
|
132
|
+
window.location.replace(body.location + window.location.hash);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
setBusy(false);
|
|
136
|
+
if (response.status === 429) {
|
|
137
|
+
lockFor(Math.max(1, Number(body && body.retryAfterSeconds) || Number(response.headers.get("retry-after")) || 30));
|
|
138
|
+
} else if (response.status === 401) {
|
|
139
|
+
password.value = "";
|
|
140
|
+
showError("login.error.invalid");
|
|
141
|
+
} else if (response.status === 403) {
|
|
142
|
+
showError("login.error.origin");
|
|
143
|
+
} else if (response.status === 404 && body && body.code === "password-not-configured") {
|
|
144
|
+
window.location.replace("/");
|
|
145
|
+
return;
|
|
146
|
+
} else {
|
|
147
|
+
showError("login.error.network");
|
|
148
|
+
}
|
|
149
|
+
password.focus();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
i18n.subscribe(renderStatic);
|
|
153
|
+
transport.hidden = window.location.protocol !== "http:" || isLoopbackHost(window.location.hostname);
|
|
154
|
+
renderStatic();
|
|
155
|
+
password.focus();
|
|
156
|
+
`;
|
|
@@ -17,7 +17,7 @@ export const APP_SCRIPT = String.raw `
|
|
|
17
17
|
// refresh catalog.refresh → sidebar, then the overview (nothing selected)
|
|
18
18
|
// or selection.reload → taskView.render
|
|
19
19
|
// select sidebar row / overview row / URL → selection.selectTask
|
|
20
|
-
// leave
|
|
20
|
+
// leave rail Workbench or Escape → selection.clearSelection
|
|
21
21
|
// write Task page or discussion afterWrite → quiet catalog refresh
|
|
22
22
|
// URL load and back/forward → catalog.applyFilters + selection.followUrl
|
|
23
23
|
import { createI18n } from "/assets/js/lib/i18n.js";
|
|
@@ -31,7 +31,9 @@ import { bindGlobalInput } from "/assets/js/views/globalInput.js";
|
|
|
31
31
|
import { bindCreateTask } from "/assets/js/views/createTask.js";
|
|
32
32
|
import { bindBodySearch } from "/assets/js/views/bodySearch.js";
|
|
33
33
|
import { readQuery } from "/assets/js/layout/router.js";
|
|
34
|
+
import { rememberPlace, linkPlaces } from "/assets/js/lib/prefs.js";
|
|
34
35
|
import { createWorkspace } from "/assets/js/layout/workspace.js";
|
|
36
|
+
import { createSidebarNav } from "/assets/js/layout/sidebarNav.js";
|
|
35
37
|
import { bindShortcuts } from "/assets/js/layout/shortcuts.js";
|
|
36
38
|
import { createTaskView } from "/assets/js/app/taskView.js";
|
|
37
39
|
import { createSelection } from "/assets/js/app/selection.js";
|
|
@@ -39,12 +41,14 @@ import { createCatalog } from "/assets/js/app/catalog.js";
|
|
|
39
41
|
|
|
40
42
|
const $ = function (selector) { return document.querySelector(selector); };
|
|
41
43
|
const el = {
|
|
42
|
-
sidebar: $(".sidebar"),
|
|
43
|
-
|
|
44
|
+
sidebar: $(".sidebar"), rail: $(".app-rail"), railWorkbench: $("#rail-workbench"),
|
|
45
|
+
sidebarScrim: $("#sidebar-scrim"), sidebarCollapse: $("#sidebar-collapse"), sidebarDivider: $("#sidebar-divider"), center: $("#center"), detail: $("#detail"),
|
|
46
|
+
search: $("#search"), source: $("#catalog-source"), filters: $("#status-filters"), attention: $("#attention-bar"), tasks: $("#task-list"),
|
|
44
47
|
catalogNext: $("#catalog-next"), catalogReset: $("#catalog-reset"), catalogCount: $("#catalog-count"),
|
|
45
48
|
catalogAttentionReset: $("#catalog-attention-reset"),
|
|
46
49
|
refresh: $("#refresh"), sync: $("#sync-state"), lastSync: $("#last-sync"), toast: $("#toast"),
|
|
47
|
-
|
|
50
|
+
dockTarget: $("#dock-target"), dockTargetLabel: $("#dock-target-label"), dockTargetIcon: $("#dock-target-icon"),
|
|
51
|
+
dockTargetOptions: $("#dock-target-options"), globalInputOpen: $("#global-input-open"), settings: $("#settings-dialog"),
|
|
48
52
|
locale: $("#locale-select"), themeOptions: $("#theme-options"),
|
|
49
53
|
dock: $("#dock"), divider: $("#dock-divider"), dockSwap: $("#dock-swap"), dockClose: $("#dock-close"),
|
|
50
54
|
dockTabDiscussion: $("#dock-tab-discussion"), dockTabSession: $("#dock-tab-session"),
|
|
@@ -52,7 +56,7 @@ const el = {
|
|
|
52
56
|
dockTabPreview: $("#dock-tab-preview"), preview: $("#dock-preview"),
|
|
53
57
|
discussion: $("#dock-discussion"), session: $("#dock-session"),
|
|
54
58
|
terminalHost: $("#terminal-host"), terminalEmpty: $("#terminal-empty"), terminalState: $("#terminal-state"),
|
|
55
|
-
sessionTargets: $("#session-targets"), terminalCli: $("#terminal-cli")
|
|
59
|
+
sessionTargets: $("#session-targets"), settingsOpen: $("#settings-open"), terminalCli: $("#terminal-cli")
|
|
56
60
|
};
|
|
57
61
|
|
|
58
62
|
// View state. Each group has exactly one writer.
|
|
@@ -86,13 +90,18 @@ let taskView = null;
|
|
|
86
90
|
let selection = null;
|
|
87
91
|
let catalog = null;
|
|
88
92
|
const workspace = createWorkspace({
|
|
89
|
-
el: el, state: state, terminal: terminal, conversation: conversation, preview: preview,
|
|
93
|
+
el: el, state: state, t: t, terminal: terminal, conversation: conversation, preview: preview,
|
|
90
94
|
sessionOpened: function () { taskView.updateSessionTargets(); }
|
|
91
95
|
});
|
|
96
|
+
const sidebarNav = createSidebarNav({
|
|
97
|
+
el: { sidebar: el.sidebar, rail: el.rail, scrim: el.sidebarScrim, collapse: el.sidebarCollapse },
|
|
98
|
+
relayout: function () { workspace.updateLayout(); }
|
|
99
|
+
});
|
|
92
100
|
taskView = createTaskView({
|
|
93
101
|
el: el, state: state, t: t, locale: locale, api: api, toast: toast, terminal: terminal, workspace: workspace,
|
|
94
102
|
selectTask: function (taskId) { return selection.selectTask(taskId); },
|
|
95
103
|
clearSelection: function () { selection.clearSelection(); },
|
|
104
|
+
revealSidebar: function () { sidebarNav.reveal(); },
|
|
96
105
|
pickAttention: function (kind) { catalog.pickAttention(kind); },
|
|
97
106
|
readPageSessions: function () { return catalog.readPageSessions(); },
|
|
98
107
|
refreshCatalog: function () { return catalog.refresh({ quiet: true }); }
|
|
@@ -114,10 +123,24 @@ bindBodySearch({ t: t, api: api, selectTask: selection.selectTask,
|
|
|
114
123
|
currentTask: function () { return state.selected; },
|
|
115
124
|
captureDraftTarget: conversation.captureDraftTarget, insertReference: conversation.insertReference });
|
|
116
125
|
bindShortcuts({
|
|
117
|
-
el: el, state: state, globalInput: globalInput, workspace: workspace,
|
|
126
|
+
el: el, state: state, globalInput: globalInput, workspace: workspace, sidebarNav: sidebarNav,
|
|
118
127
|
selection: selection, catalog: catalog, taskView: taskView
|
|
119
128
|
});
|
|
120
|
-
|
|
129
|
+
// Workbench <-> Models/Settings: each side remembers where the user was, so a
|
|
130
|
+
// round trip returns to the same filters, Task and management section.
|
|
131
|
+
function rememberWorkbench() { rememberPlace("workbench", window.location.pathname + window.location.search); }
|
|
132
|
+
el.rail.querySelectorAll("a[data-nav-area]").forEach(function (link) { link.addEventListener("click", rememberWorkbench); });
|
|
133
|
+
// The rail's Workbench entry on the workbench itself returns to the overview.
|
|
134
|
+
el.railWorkbench.addEventListener("click", function (event) {
|
|
135
|
+
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
136
|
+
event.preventDefault();
|
|
137
|
+
sidebarNav.closeDrawer();
|
|
138
|
+
selection.clearSelection();
|
|
139
|
+
});
|
|
140
|
+
window.addEventListener("pagehide", rememberWorkbench);
|
|
141
|
+
function linkAll() { linkPlaces(el.rail); linkPlaces(el.sidebar); }
|
|
142
|
+
window.addEventListener("pageshow", linkAll);
|
|
143
|
+
linkAll();
|
|
121
144
|
|
|
122
145
|
// The URL names the catalog filters and the selected Task (with its section).
|
|
123
146
|
function applyStateFromUrl() {
|
|
@@ -140,9 +163,10 @@ i18n.subscribe(function () {
|
|
|
140
163
|
|
|
141
164
|
workspace.updateLayout();
|
|
142
165
|
taskView.showOverview();
|
|
166
|
+
// The panel starts closed: name its owner without polling until it opens.
|
|
143
167
|
conversation.open({ scope: "global", roleName: "operator" });
|
|
168
|
+
conversation.close();
|
|
144
169
|
applyStateFromUrl();
|
|
145
|
-
workspace.restoreDefaultMode();
|
|
146
170
|
catalog.refresh();
|
|
147
171
|
window.setInterval(function () { catalog.refresh({ quiet: true }); }, 5000);
|
|
148
172
|
`;
|
|
@@ -2,5 +2,23 @@ export const MANAGE_SCRIPT = String.raw `
|
|
|
2
2
|
import "/assets/js/app/settings.js";
|
|
3
3
|
import "/assets/js/app/models.js";
|
|
4
4
|
import { startManagement } from "/assets/js/layout/manageShell.js";
|
|
5
|
+
import { createSidebarNav } from "/assets/js/layout/sidebarNav.js";
|
|
6
|
+
import { isEditing } from "/assets/js/lib/dom.js";
|
|
7
|
+
|
|
8
|
+
// The section list behaves like the workbench task list: collapsible beside
|
|
9
|
+
// the content, a drawer (with the global rail) on narrow screens.
|
|
10
|
+
const sidebarNav = createSidebarNav({
|
|
11
|
+
el: { sidebar: document.querySelector(".manage-rail"), rail: document.querySelector(".app-rail"), scrim: document.querySelector("#sidebar-scrim"),
|
|
12
|
+
collapse: document.querySelector("#sidebar-collapse") },
|
|
13
|
+
relayout: function () {}
|
|
14
|
+
});
|
|
15
|
+
document.querySelectorAll("[data-sidebar-toggle]").forEach(function (button) {
|
|
16
|
+
button.addEventListener("click", sidebarNav.reveal);
|
|
17
|
+
});
|
|
18
|
+
document.addEventListener("keydown", function (event) {
|
|
19
|
+
if (document.querySelector("dialog[open]") || event.metaKey || event.ctrlKey || event.altKey) return;
|
|
20
|
+
if (event.key === "Escape" && sidebarNav.drawerOpen()) { sidebarNav.closeDrawer(); return; }
|
|
21
|
+
if (event.key === "[" && !isEditing(document.activeElement)) sidebarNav.toggle();
|
|
22
|
+
});
|
|
5
23
|
startManagement();
|
|
6
24
|
`;
|
|
@@ -74,6 +74,7 @@ function clearSelection(deps) {
|
|
|
74
74
|
syncUrl(deps.state, !urlTaskId());
|
|
75
75
|
deps.workspace.updateLayout();
|
|
76
76
|
deps.taskView.showOverview();
|
|
77
|
+
deps.taskView.revealPage();
|
|
77
78
|
deps.renderTaskList();
|
|
78
79
|
deps.taskView.updateSessionTargets();
|
|
79
80
|
}
|
|
@@ -96,9 +97,11 @@ function followUrl(deps, params) {
|
|
|
96
97
|
return;
|
|
97
98
|
}
|
|
98
99
|
if (state.selected && !canLeave(deps)) { syncUrl(state, true); return; }
|
|
100
|
+
const leaving = !!state.selected;
|
|
99
101
|
dropSelection(deps);
|
|
100
102
|
deps.workspace.updateLayout();
|
|
101
103
|
deps.taskView.showOverview();
|
|
104
|
+
if (leaving) deps.taskView.revealPage();
|
|
102
105
|
}
|
|
103
106
|
|
|
104
107
|
async function load(deps, taskId, navigate) {
|
|
@@ -120,7 +123,7 @@ async function load(deps, taskId, navigate) {
|
|
|
120
123
|
state.detail = detail;
|
|
121
124
|
state.detailKey = fingerprint(core);
|
|
122
125
|
deps.taskView.render(navigate);
|
|
123
|
-
if (navigate) deps.taskView.applySection(readQuery().get("section"));
|
|
126
|
+
if (navigate) { deps.taskView.applySection(readQuery().get("section")); deps.taskView.revealPage(); }
|
|
124
127
|
observe(deps, taskId, detail);
|
|
125
128
|
}
|
|
126
129
|
|
|
@@ -14,6 +14,7 @@ import { updateObservation } from "/assets/js/views/task/observation.js";
|
|
|
14
14
|
import { renderDiscussion, discussionHasUnsent } from "/assets/js/views/dock/discussion.js";
|
|
15
15
|
import { sameTarget } from "/assets/js/views/dock/terminal.js";
|
|
16
16
|
import { setSectionParam } from "/assets/js/layout/router.js";
|
|
17
|
+
import { enter } from "/assets/js/lib/motion.js";
|
|
17
18
|
|
|
18
19
|
// Only the observed identity participates: observation timestamps advance on
|
|
19
20
|
// every poll and would otherwise re-render continuously.
|
|
@@ -29,7 +30,7 @@ function runtimeSignature(detail) {
|
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
// deps: { el, state, t, locale, api, toast, terminal, workspace,
|
|
32
|
-
// selectTask, clearSelection, pickAttention, readPageSessions, refreshCatalog }
|
|
33
|
+
// selectTask, clearSelection, revealSidebar, pickAttention, readPageSessions, refreshCatalog }
|
|
33
34
|
// el #detail, #center and the dock discussion
|
|
34
35
|
// state reads selected, detail, detailKey and the catalog fields the
|
|
35
36
|
// overview shows; writes activeTab
|
|
@@ -37,7 +38,8 @@ function runtimeSignature(detail) {
|
|
|
37
38
|
// terminal the dock's native Session (views/dock/terminal)
|
|
38
39
|
// workspace layout/workspace: dock toggles and opening Sessions
|
|
39
40
|
// selectTask(taskId) an overview row was chosen (app/selection)
|
|
40
|
-
// clearSelection() the Task page's
|
|
41
|
+
// clearSelection() the Task page's "Tasks" crumb (app/selection)
|
|
42
|
+
// revealSidebar() the page header's task-list switch (layout/sidebarNav)
|
|
41
43
|
// pickAttention(kind), readPageSessions() overview actions (app/catalog)
|
|
42
44
|
// refreshCatalog() quiet catalog refresh after a write; resolves when done
|
|
43
45
|
export function createTaskView(deps) {
|
|
@@ -59,7 +61,10 @@ export function createTaskView(deps) {
|
|
|
59
61
|
showOverview: function () { showOverview(deps); },
|
|
60
62
|
updateSessionTargets: function () { updateSessionTargets(deps); },
|
|
61
63
|
switchTab: function (tab, options) { switchTab(deps, tab, options); },
|
|
62
|
-
applySection: function (section) { applySection(deps, section); }
|
|
64
|
+
applySection: function (section) { applySection(deps, section); },
|
|
65
|
+
// The page content after an explicit navigation (a Task opened, back to
|
|
66
|
+
// the overview) rises in; polling redraws never call this.
|
|
67
|
+
revealPage: function () { reveal(deps.el.detail.querySelector(":scope > .page")); }
|
|
63
68
|
};
|
|
64
69
|
return view.controller;
|
|
65
70
|
}
|
|
@@ -143,9 +148,15 @@ function detailContext(view) {
|
|
|
143
148
|
const api = deps.api;
|
|
144
149
|
return {
|
|
145
150
|
activeTab: deps.state.activeTab,
|
|
146
|
-
onTab:
|
|
151
|
+
onTab: function (tab, options) {
|
|
152
|
+
const changed = tab !== deps.state.activeTab;
|
|
153
|
+
switchTab(deps, tab, options);
|
|
154
|
+
if (changed) reveal(deps.el.detail.querySelector(".tab-panel:not([hidden])"));
|
|
155
|
+
},
|
|
147
156
|
onBack: deps.clearSelection,
|
|
157
|
+
revealSidebar: deps.revealSidebar,
|
|
148
158
|
showDock: deps.workspace.toggleDock,
|
|
159
|
+
toggleOperator: deps.workspace.toggleOperator,
|
|
149
160
|
openConversation: function (materials) {
|
|
150
161
|
deps.workspace.openConversation({ scope: "task", taskId: deps.state.selected, roleName: "leader" }, materials);
|
|
151
162
|
},
|
|
@@ -253,8 +264,14 @@ function showOverview(deps) {
|
|
|
253
264
|
select: deps.selectTask,
|
|
254
265
|
filterAttention: deps.pickAttention,
|
|
255
266
|
readSessions: deps.readPageSessions,
|
|
256
|
-
|
|
267
|
+
toggleOperator: deps.workspace.toggleOperator,
|
|
268
|
+
revealSidebar: deps.revealSidebar
|
|
257
269
|
});
|
|
270
|
+
deps.workspace.syncDockButtons();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function reveal(element) {
|
|
274
|
+
if (element && typeof enter === "function") enter(element, "rise");
|
|
258
275
|
}
|
|
259
276
|
|
|
260
277
|
function switchTab(deps, tab, options) {
|