querysub 0.545.0 → 0.547.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/appSecrets.ts +2 -3
- package/package.json +2 -2
- package/src/-a-archives/archives2.ts +19 -11
- package/src/deployManager/MachinesPage.tsx +3 -0
- package/src/deployManager/components/RouteConfigView.tsx +349 -0
- package/src/deployManager/components/StoragePage.tsx +265 -0
- package/src/deployManager/urlParams.ts +1 -1
- package/src/misc/random.ts +1 -1
package/appSecrets.ts
CHANGED
|
@@ -74,9 +74,8 @@ export const getCloudflareCreds = lazy(async (): Promise<{ key: string; email: s
|
|
|
74
74
|
|
|
75
75
|
/** Serves the secret keys sliftutils' getSecret expects, reading them the way querysub already does: backblaze creds off disk (getBackblazePath), cloudflare creds from the keys archives bucket (with its local file fallback). */
|
|
76
76
|
export async function getAppSecret(key: string): Promise<string | undefined> {
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
// Querysub;
|
|
77
|
+
// Import query sub so our logs go to our actual log server. Otherwise we won't know if we get any errors.
|
|
78
|
+
Querysub;
|
|
80
79
|
if (key.startsWith("backblaze.json.")) {
|
|
81
80
|
let creds = JSON.parse(fs.readFileSync(getBackblazePath(), "utf8")) as { applicationKeyId?: string; applicationKey?: string };
|
|
82
81
|
if (key === "backblaze.json.applicationKeyId") return creds.applicationKeyId;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "querysub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.547.0",
|
|
4
4
|
"main": "index.js",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"node-forge": "https://github.com/sliftist/forge#e618181b469b07bdc70b968b0391beb8ef5fecd6",
|
|
72
72
|
"pako": "^2.1.0",
|
|
73
73
|
"peggy": "^5.0.6",
|
|
74
|
-
"sliftutils": "^1.7.
|
|
74
|
+
"sliftutils": "^1.7.33",
|
|
75
75
|
"socket-function": "^1.2.26",
|
|
76
76
|
"terser": "^5.31.0",
|
|
77
77
|
"typenode": "^6.6.1",
|
|
@@ -3,6 +3,9 @@ import { createArchives } from "sliftutils/storage/remoteStorage/createArchives"
|
|
|
3
3
|
import { cache, cacheJSONArgsEqual } from "socket-function/src/caching";
|
|
4
4
|
import { formatDateTime } from "socket-function/src/formatting/format";
|
|
5
5
|
import { getDomain } from "../config";
|
|
6
|
+
import { timeInMinute } from "socket-function/src/misc";
|
|
7
|
+
|
|
8
|
+
export const STORAGE_ACCOUNT = "root";
|
|
6
9
|
|
|
7
10
|
function createSourceWindows(
|
|
8
11
|
overrides: Partial<RemoteConfigBase>,
|
|
@@ -32,36 +35,34 @@ function createSourceWindows(
|
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
function archiveBuilder(bucket: string, overrides: Partial<RemoteConfigBase>) {
|
|
35
|
-
const HETZNER = `https://65-109-93-113.querysubtest.com:5233/file
|
|
36
|
-
const ONTARIO = `https://99-250-124-91.querysubtest.com:5234/file
|
|
38
|
+
const HETZNER = `https://65-109-93-113.querysubtest.com:5233/file/${STORAGE_ACCOUNT}/${bucket}/storage/storagerouting.json`;
|
|
39
|
+
const ONTARIO = `https://99-250-124-91.querysubtest.com:5234/file/${STORAGE_ACCOUNT}/${bucket}/storage/storagerouting.json`;
|
|
37
40
|
const BACKBLAZE = `https://f002.backblazeb2.com/file/${bucket}/storage/storagerouting.json`;
|
|
38
41
|
let sources = createSourceWindows(overrides, [
|
|
39
42
|
{
|
|
40
43
|
startTime: 0,
|
|
41
44
|
sources: [
|
|
42
|
-
{ type: "remote", url: ONTARIO, route: [0.5
|
|
43
|
-
{ type: "remote", url: HETZNER, route: [0,
|
|
45
|
+
{ type: "remote", url: ONTARIO, route: [0, 0.5], },
|
|
46
|
+
{ type: "remote", url: HETZNER, route: [0.5, 1], },
|
|
44
47
|
{ type: "remote", url: ONTARIO, },
|
|
45
48
|
{ type: "remote", url: HETZNER, },
|
|
46
49
|
{ type: "backblaze", url: BACKBLAZE, },
|
|
47
50
|
]
|
|
48
51
|
},
|
|
49
|
-
/*
|
|
50
52
|
{
|
|
51
|
-
startTime: +new Date("2026-07-20
|
|
53
|
+
startTime: +new Date("2026-07-20 18:00:00-04:00"),
|
|
52
54
|
sources: [
|
|
53
|
-
{ type: "remote", url: ONTARIO, route: [0,
|
|
54
|
-
{ type: "remote", url: HETZNER, route: [0.5
|
|
55
|
+
{ type: "remote", url: ONTARIO, route: [0.5, 1], },
|
|
56
|
+
{ type: "remote", url: HETZNER, route: [0, 0.5], },
|
|
55
57
|
{ type: "remote", url: ONTARIO, },
|
|
56
58
|
{ type: "remote", url: HETZNER, },
|
|
57
59
|
{ type: "backblaze", url: BACKBLAZE, },
|
|
58
60
|
]
|
|
59
61
|
}
|
|
60
|
-
*/
|
|
61
62
|
]);
|
|
62
63
|
|
|
63
64
|
return createArchives({
|
|
64
|
-
version:
|
|
65
|
+
version: 14,
|
|
65
66
|
sources,
|
|
66
67
|
});
|
|
67
68
|
}
|
|
@@ -100,6 +101,13 @@ function getSafeDomain() {
|
|
|
100
101
|
export function getArchives2(folder: string) {
|
|
101
102
|
return nestBucket(folder, archivesBuilderCache(getSafeDomain(), { noFullSync: true, }));
|
|
102
103
|
}
|
|
104
|
+
|
|
105
|
+
/** The rights are buffered server-side, so if you do many writes at once, it won't slow down the disk and it won't result in many writes to back backblades. Of course, this does mean if the server crashes, you do lose some data.
|
|
106
|
+
*/
|
|
107
|
+
export function getArchives2Buffered(folder: string, bufferDelay = timeInMinute * 5) {
|
|
108
|
+
return nestBucket(folder, archivesBuilderCache(getSafeDomain() + "-fast", { noFullSync: true, fast: true, writeDelay: bufferDelay }));
|
|
109
|
+
}
|
|
110
|
+
|
|
103
111
|
export function getArchives2PrivateImmutable(folder: string) {
|
|
104
112
|
return nestBucket(folder, archivesBuilderCache(getSafeDomain() + "-private-immutable", { noFullSync: true, immutable: true }));
|
|
105
113
|
}
|
|
@@ -112,5 +120,5 @@ export function getArchives2PublicImmutable(folder: string) {
|
|
|
112
120
|
}
|
|
113
121
|
export function getArchives2Public(folder: string) {
|
|
114
122
|
let domain = getSafeDomain();
|
|
115
|
-
return nestBucket(folder, archivesBuilderCache(domain + "-public", {
|
|
123
|
+
return nestBucket(folder, archivesBuilderCache(domain + "-public", { public: true, allowedOrigins: getAllowedOrigins(getDomain()) }));
|
|
116
124
|
}
|
|
@@ -9,6 +9,7 @@ import { ServiceDetailPage } from "./components/ServiceDetailPage";
|
|
|
9
9
|
import { MachineDetailPage } from "./components/MachineDetailPage";
|
|
10
10
|
import { Anchor } from "../library-components/ATag";
|
|
11
11
|
import { DeployPage } from "./components/DeployPage";
|
|
12
|
+
import { StoragePage } from "./components/StoragePage";
|
|
12
13
|
|
|
13
14
|
export class MachinesPage extends qreact.Component {
|
|
14
15
|
private renderTabs() {
|
|
@@ -20,6 +21,7 @@ export class MachinesPage extends qreact.Component {
|
|
|
20
21
|
{ key: "machines", label: "Machines", otherKeys: ["machine-detail"] },
|
|
21
22
|
{ key: "services", label: "Services", otherKeys: ["service-detail"] },
|
|
22
23
|
{ key: "deploy", label: "Deploy", otherKeys: [] },
|
|
24
|
+
{ key: "storage", label: "Storage", otherKeys: [] },
|
|
23
25
|
].map(tab => {
|
|
24
26
|
let isActive = currentViewParam.value === tab.key || tab.otherKeys.includes(currentViewParam.value);
|
|
25
27
|
return <Anchor noStyles key={tab.key}
|
|
@@ -47,6 +49,7 @@ export class MachinesPage extends qreact.Component {
|
|
|
47
49
|
{currentViewParam.value === "service-detail" && <ServiceDetailPage />}
|
|
48
50
|
{currentViewParam.value === "machine-detail" && <MachineDetailPage />}
|
|
49
51
|
{currentViewParam.value === "deploy" && <DeployPage />}
|
|
52
|
+
{currentViewParam.value === "storage" && <StoragePage />}
|
|
50
53
|
</div>
|
|
51
54
|
</div>;
|
|
52
55
|
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { qreact } from "../../4-dom/qreact";
|
|
2
|
+
import { Querysub } from "../../4-querysub/Querysub";
|
|
3
|
+
import { css } from "typesafecss";
|
|
4
|
+
import { sort } from "socket-function/src/misc";
|
|
5
|
+
import { formatNumber, formatDateTime, formatTime } from "socket-function/src/formatting/format";
|
|
6
|
+
import { parseHostedUrl, parseBackblazeUrl } from "sliftutils/storage/remoteStorage/remoteConfig";
|
|
7
|
+
import { FULL_VALID_WINDOW, FULL_ROUTE } from "sliftutils/storage/IArchives";
|
|
8
|
+
import type { RemoteConfig, RemoteConfigBase, HostedConfig, BackblazeConfig } from "sliftutils/storage/IArchives";
|
|
9
|
+
import { showModal } from "../../5-diagnostics/Modal";
|
|
10
|
+
import { FullscreenModal } from "../../5-diagnostics/FullscreenModal";
|
|
11
|
+
import { Button } from "../../library-components/Button";
|
|
12
|
+
import type { StorageServerBuckets } from "./StoragePage";
|
|
13
|
+
|
|
14
|
+
module.hotreload = true;
|
|
15
|
+
|
|
16
|
+
type Source = HostedConfig | BackblazeConfig;
|
|
17
|
+
|
|
18
|
+
const DEFAULT_HTTPS_PORT = 443;
|
|
19
|
+
const TAG_COLOR = { h: 210, s: 45, l: 88 };
|
|
20
|
+
const WARNING_COLOR = { h: 35, s: 90, l: 85 };
|
|
21
|
+
const SOURCE_BORDER_COLOR = { h: 0, s: 0, l: 75 };
|
|
22
|
+
const ACTIVE_COLOR = { h: 130, s: 55, l: 85 };
|
|
23
|
+
const PAST_COLOR = { h: 0, s: 0, l: 90 };
|
|
24
|
+
const FUTURE_COLOR = { h: 45, s: 80, l: 85 };
|
|
25
|
+
// Windows are days or hours wide, so the countdown only has to be roughly right
|
|
26
|
+
const TIME_REFRESH_INTERVAL = 60 * 1000;
|
|
27
|
+
const WINDOW_HEADER_SIZE = 13;
|
|
28
|
+
const BUCKET_TITLE_SIZE = 15;
|
|
29
|
+
const GROUP_BODY_INDENT = 16;
|
|
30
|
+
const TAG_FONT_SIZE = 11;
|
|
31
|
+
const JSON_INDENT = 4;
|
|
32
|
+
// Stands in for the bucket a config belongs to, so buckets that differ only by their own name group together
|
|
33
|
+
const OWN_BUCKET_PLACEHOLDER = "*";
|
|
34
|
+
|
|
35
|
+
/** String sources are the shorthand for an always-valid, unsharded backblaze bucket. */
|
|
36
|
+
function normalizeSource(source: RemoteConfigBase): Source {
|
|
37
|
+
if (typeof source !== "string") return source;
|
|
38
|
+
return { type: "backblaze", url: source, validWindow: FULL_VALID_WINDOW };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The bucket coordinates of a source URL, so the display doesn't have to show the whole routing-file URL. A source pointing at the bucket the config belongs to shows the placeholder instead of repeating the name. */
|
|
42
|
+
function describeUrl(source: Source, ownBucketName: string): string {
|
|
43
|
+
let maskBucket = (bucketName: string) => bucketName === ownBucketName && OWN_BUCKET_PLACEHOLDER || bucketName;
|
|
44
|
+
try {
|
|
45
|
+
if (source.type === "backblaze") {
|
|
46
|
+
return `b2 ${maskBucket(parseBackblazeUrl(source.url).bucketName)}`;
|
|
47
|
+
}
|
|
48
|
+
let { address, port, account, bucketName } = parseHostedUrl(source.url);
|
|
49
|
+
let host = port === DEFAULT_HTTPS_PORT && address || `${address}:${port}`;
|
|
50
|
+
return `${host} ${account}/${maskBucket(bucketName)}`;
|
|
51
|
+
} catch {
|
|
52
|
+
return source.url;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getSourceKey(source: Source, ownBucketName: string): string {
|
|
57
|
+
return JSON.stringify({ ...source, url: describeUrl(source, ownBucketName) });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Identifies a config independently of which bucket it configures, so identical configs on different buckets collapse into one display. */
|
|
61
|
+
function getVariantKey(variant: ConfigVariant, ownBucketName: string): string {
|
|
62
|
+
return JSON.stringify({ version: variant.version, sources: variant.sources.map(x => getSourceKey(x, ownBucketName)) });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatWindowTime(time: number): string {
|
|
66
|
+
if (time <= FULL_VALID_WINDOW[0]) return "the beginning";
|
|
67
|
+
if (time >= FULL_VALID_WINDOW[1]) return "forever";
|
|
68
|
+
return formatDateTime(time);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A missing version sorts below any explicit one, matching getConfigVersion
|
|
72
|
+
function getSortVersion(variant: ConfigVariant): number {
|
|
73
|
+
return variant.version ?? -1;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type WindowStatus = {
|
|
77
|
+
icon: string;
|
|
78
|
+
text: string;
|
|
79
|
+
color: { h: number; s: number; l: number };
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function getWindowStatus(window: [number, number], now: number): WindowStatus {
|
|
83
|
+
let [start, end] = window;
|
|
84
|
+
if (now < start) {
|
|
85
|
+
return { icon: "⏳", text: `starts in ${formatTime(start - now)}`, color: FUTURE_COLOR };
|
|
86
|
+
}
|
|
87
|
+
if (now >= end) {
|
|
88
|
+
return { icon: "✔", text: `ended ${formatTime(now - end)} ago`, color: PAST_COLOR };
|
|
89
|
+
}
|
|
90
|
+
if (end >= FULL_VALID_WINDOW[1]) {
|
|
91
|
+
return { icon: "●", text: "active", color: ACTIVE_COLOR };
|
|
92
|
+
}
|
|
93
|
+
return { icon: "●", text: `active, ends in ${formatTime(end - now)}`, color: ACTIVE_COLOR };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function windowsOverlap(a: [number, number], b: [number, number]): boolean {
|
|
97
|
+
return a[0] < b[1] && b[0] < a[1];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
type WindowCluster = {
|
|
101
|
+
window: [number, number];
|
|
102
|
+
sources: Source[];
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** Sources whose valid windows overlap describe one configuration of the bucket, so they merge into a single displayed window. Sources keep their config order within a cluster - the order sources are listed in decides which one shadows which. */
|
|
106
|
+
function getWindowClusters(sources: Source[]): WindowCluster[] {
|
|
107
|
+
let byStart = [...sources];
|
|
108
|
+
sort(byStart, x => x.validWindow[0]);
|
|
109
|
+
let windows: [number, number][] = [];
|
|
110
|
+
for (let source of byStart) {
|
|
111
|
+
let last = windows[windows.length - 1];
|
|
112
|
+
if (last && windowsOverlap(last, source.validWindow)) {
|
|
113
|
+
last[0] = Math.min(last[0], source.validWindow[0]);
|
|
114
|
+
last[1] = Math.max(last[1], source.validWindow[1]);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
windows.push([...source.validWindow]);
|
|
118
|
+
}
|
|
119
|
+
let clusters: WindowCluster[] = windows.map(window => ({ window, sources: [] }));
|
|
120
|
+
for (let source of sources) {
|
|
121
|
+
let cluster = clusters.find(x => windowsOverlap(x.window, source.validWindow));
|
|
122
|
+
if (!cluster) continue;
|
|
123
|
+
cluster.sources.push(source);
|
|
124
|
+
}
|
|
125
|
+
return clusters;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type ConfigVariant = {
|
|
129
|
+
servers: string[];
|
|
130
|
+
sources: Source[];
|
|
131
|
+
version?: number;
|
|
132
|
+
rawConfig: RemoteConfig;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export type ConfigConflict = {
|
|
136
|
+
bucketName: string;
|
|
137
|
+
variant: ConfigVariant;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export type RouteConfigGroup = {
|
|
141
|
+
buckets: { bucketName: string; rawConfig: RemoteConfig }[];
|
|
142
|
+
consensus: ConfigVariant;
|
|
143
|
+
conflicts: ConfigConflict[];
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/** Groups every server's view of each bucket's routing config, taking the most widely reported one as the truth, surfacing the rest as conflicts, then collapsing buckets that share a config. */
|
|
147
|
+
export function getRouteConfigGroups(servers: StorageServerBuckets[]): RouteConfigGroup[] {
|
|
148
|
+
let byBucket = new Map<string, Map<string, ConfigVariant>>();
|
|
149
|
+
for (let server of servers) {
|
|
150
|
+
for (let bucket of server.buckets || []) {
|
|
151
|
+
let remoteConfig: RemoteConfig | undefined = bucket.config?.remoteConfig;
|
|
152
|
+
if (!remoteConfig) continue;
|
|
153
|
+
let sources = remoteConfig.sources.map(normalizeSource);
|
|
154
|
+
let variants = byBucket.get(bucket.bucketName);
|
|
155
|
+
if (!variants) {
|
|
156
|
+
variants = new Map();
|
|
157
|
+
byBucket.set(bucket.bucketName, variants);
|
|
158
|
+
}
|
|
159
|
+
let key = JSON.stringify({ version: remoteConfig.version, sources });
|
|
160
|
+
let variant = variants.get(key);
|
|
161
|
+
if (!variant) {
|
|
162
|
+
variant = { servers: [], sources, version: remoteConfig.version, rawConfig: remoteConfig };
|
|
163
|
+
variants.set(key, variant);
|
|
164
|
+
}
|
|
165
|
+
variant.servers.push(server.url);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let groups = new Map<string, RouteConfigGroup>();
|
|
170
|
+
for (let [bucketName, variants] of byBucket) {
|
|
171
|
+
let all = [...variants.values()];
|
|
172
|
+
// Ties keep insertion order, so the first server to report a config wins
|
|
173
|
+
let consensus = all.reduce((best, x) => x.servers.length > best.servers.length && x || best);
|
|
174
|
+
let conflicts = all.filter(x => x !== consensus).map(variant => ({ bucketName, variant }));
|
|
175
|
+
let key = getVariantKey(consensus, bucketName);
|
|
176
|
+
let group = groups.get(key);
|
|
177
|
+
if (!group) {
|
|
178
|
+
group = { buckets: [], consensus, conflicts: [] };
|
|
179
|
+
groups.set(key, group);
|
|
180
|
+
}
|
|
181
|
+
group.buckets.push({ bucketName, rawConfig: consensus.rawConfig });
|
|
182
|
+
for (let server of consensus.servers) {
|
|
183
|
+
if (group.consensus.servers.includes(server)) continue;
|
|
184
|
+
group.consensus.servers.push(server);
|
|
185
|
+
}
|
|
186
|
+
group.conflicts.push(...conflicts);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let results = [...groups.values()];
|
|
190
|
+
for (let group of results) {
|
|
191
|
+
sort(group.buckets, x => x.bucketName);
|
|
192
|
+
}
|
|
193
|
+
sort(results, x => -getSortVersion(x.consensus));
|
|
194
|
+
return results;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function showConfigModal(bucketName: string, rawConfig: RemoteConfig): void {
|
|
198
|
+
let close = showModal({
|
|
199
|
+
content: <FullscreenModal onCancel={() => close.close()}>
|
|
200
|
+
<div className={css.vbox(10)}>
|
|
201
|
+
<div className={css.boldStyle}>{bucketName}</div>
|
|
202
|
+
<div className={css.fontFamily("monospace").whiteSpace("pre").overflow("auto")}>
|
|
203
|
+
{JSON.stringify(rawConfig, undefined, JSON_INDENT)}
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
</FullscreenModal>
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
class Tag extends qreact.Component<{ icon: string; text: string; warning?: boolean; color?: { h: number; s: number; l: number } }> {
|
|
211
|
+
render() {
|
|
212
|
+
let color = this.props.color || this.props.warning && WARNING_COLOR || TAG_COLOR;
|
|
213
|
+
return <div className={
|
|
214
|
+
css.hbox(4).pad2(6, 1).fontSize(TAG_FONT_SIZE).whiteSpace("nowrap")
|
|
215
|
+
.hsl(color.h, color.s, color.l).bord2(color.h, color.s, color.l - 20)
|
|
216
|
+
}>
|
|
217
|
+
<span>{this.props.icon}</span>
|
|
218
|
+
<span>{this.props.text}</span>
|
|
219
|
+
</div>;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function getSourceTags(source: Source): { icon: string; text: string }[] {
|
|
224
|
+
let tags: { icon: string; text: string }[] = [];
|
|
225
|
+
if (source.public) tags.push({ icon: "🌐", text: "public" });
|
|
226
|
+
if (source.immutable) tags.push({ icon: "🔒", text: "immutable" });
|
|
227
|
+
if (source.noFullSync) tags.push({ icon: "⇣", text: "noFullSync" });
|
|
228
|
+
if (source.intermediate) tags.push({ icon: "⏳", text: "intermediate" });
|
|
229
|
+
if (source.type === "remote") {
|
|
230
|
+
if (source.fast) tags.push({ icon: "⚡", text: "fast" });
|
|
231
|
+
if (source.rawDisk) tags.push({ icon: "💽", text: "rawDisk" });
|
|
232
|
+
if (source.writeDelay !== undefined) tags.push({ icon: "⏱", text: `writeDelay ${formatTime(source.writeDelay)}` });
|
|
233
|
+
}
|
|
234
|
+
if (source.readerDiskLimit !== undefined) tags.push({ icon: "📦", text: `readCache ${formatNumber(source.readerDiskLimit)}B` });
|
|
235
|
+
return tags;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
class SourceRow extends qreact.Component<{ source: Source; window: [number, number]; ownBucketName: string }> {
|
|
239
|
+
render() {
|
|
240
|
+
let { source, window, ownBucketName } = this.props;
|
|
241
|
+
let [routeStart, routeEnd] = source.route || FULL_ROUTE;
|
|
242
|
+
let [start, end] = source.validWindow;
|
|
243
|
+
// A source that stops short of the window it's displayed under has a gap where the bucket has no configuration
|
|
244
|
+
let partial = start > window[0] || end < window[1];
|
|
245
|
+
return <div className={css.fillWidth}>
|
|
246
|
+
<div className={
|
|
247
|
+
css.marginLeft(`${routeStart * 100}%`).width(`${(routeEnd - routeStart) * 100}%`)
|
|
248
|
+
.hbox(6).pad2(8, 4).boxSizing("border-box").whiteSpace("nowrap")
|
|
249
|
+
.hsl(0, 0, 100).bord2(SOURCE_BORDER_COLOR.h, SOURCE_BORDER_COLOR.s, SOURCE_BORDER_COLOR.l)
|
|
250
|
+
}>
|
|
251
|
+
<div className={css.boldStyle}>{describeUrl(source, ownBucketName)}</div>
|
|
252
|
+
{source.route && <div className={css.fontSize(TAG_FONT_SIZE).colorhsl(0, 0, 40)}>
|
|
253
|
+
route {routeStart} – {routeEnd}
|
|
254
|
+
</div>}
|
|
255
|
+
{getSourceTags(source).map(tag => <Tag key={tag.text} icon={tag.icon} text={tag.text} />)}
|
|
256
|
+
{partial && <Tag
|
|
257
|
+
icon="⚠"
|
|
258
|
+
warning={true}
|
|
259
|
+
text={`only valid ${formatWindowTime(start)} → ${formatWindowTime(end)}`}
|
|
260
|
+
/>}
|
|
261
|
+
</div>
|
|
262
|
+
</div>;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
class ConflictWarning extends qreact.Component<{ consensus: ConfigVariant; conflict: ConfigConflict }> {
|
|
267
|
+
render() {
|
|
268
|
+
let { consensus, conflict } = this.props;
|
|
269
|
+
let { bucketName, variant } = conflict;
|
|
270
|
+
let consensusKeys = new Set(consensus.sources.map(x => getSourceKey(x, bucketName)));
|
|
271
|
+
let conflictKeys = new Set(variant.sources.map(x => getSourceKey(x, bucketName)));
|
|
272
|
+
let extra = variant.sources.filter(x => !consensusKeys.has(getSourceKey(x, bucketName)));
|
|
273
|
+
let missing = consensus.sources.filter(x => !conflictKeys.has(getSourceKey(x, bucketName)));
|
|
274
|
+
let describeDifference = (source: Source, label: string) => <div key={label + getSourceKey(source, bucketName)} className={css.hbox(6).wrap}>
|
|
275
|
+
<div className={css.boldStyle}>{label}</div>
|
|
276
|
+
<div>{describeUrl(source, bucketName)}</div>
|
|
277
|
+
<div className={css.fontSize(TAG_FONT_SIZE)}>
|
|
278
|
+
route {(source.route || FULL_ROUTE).join(" – ")}, valid {formatWindowTime(source.validWindow[0])} → {formatWindowTime(source.validWindow[1])}
|
|
279
|
+
</div>
|
|
280
|
+
</div>;
|
|
281
|
+
return <div className={css.vbox(6).pad2(10, 8).hsl(WARNING_COLOR.h, WARNING_COLOR.s, WARNING_COLOR.l).bord2(WARNING_COLOR.h, WARNING_COLOR.s, WARNING_COLOR.l - 25)}>
|
|
282
|
+
<div className={css.boldStyle}>
|
|
283
|
+
⚠ {bucketName}: {variant.servers.length} server{variant.servers.length === 1 && "" || "s"} report a different routing config (version {String(variant.version ?? "none")}, consensus is version {String(consensus.version ?? "none")})
|
|
284
|
+
</div>
|
|
285
|
+
<div className={css.vbox(2).fontSize(TAG_FONT_SIZE)}>
|
|
286
|
+
{variant.servers.map(server => <div key={server}>{server}</div>)}
|
|
287
|
+
</div>
|
|
288
|
+
{extra.map(source => describeDifference(source, "only there:"))}
|
|
289
|
+
{missing.map(source => describeDifference(source, "missing there:"))}
|
|
290
|
+
</div>;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
class RouteConfigGroupView extends qreact.Component<{ group: RouteConfigGroup }> {
|
|
295
|
+
render() {
|
|
296
|
+
let { buckets, consensus, conflicts } = this.props.group;
|
|
297
|
+
let ownBucketName = buckets[0].bucketName;
|
|
298
|
+
let clusters = getWindowClusters(consensus.sources);
|
|
299
|
+
let now = Querysub.timeDelayed(TIME_REFRESH_INTERVAL);
|
|
300
|
+
return <div className={css.vbox(10).fillWidth}>
|
|
301
|
+
<div className={css.hbox(8).wrap.fontSize(WINDOW_HEADER_SIZE)}>
|
|
302
|
+
<div className={css.hbox(4).wrap}>
|
|
303
|
+
{buckets.map((bucket, index) => <div key={bucket.bucketName} className={css.hbox(4)}>
|
|
304
|
+
{index > 0 && <div className={css.colorhsl(0, 0, 60)}>|</div>}
|
|
305
|
+
<Button
|
|
306
|
+
flavor="tiny"
|
|
307
|
+
className={css.fontSize(BUCKET_TITLE_SIZE)}
|
|
308
|
+
onClick={() => showConfigModal(bucket.bucketName, bucket.rawConfig)}
|
|
309
|
+
>
|
|
310
|
+
{bucket.bucketName}
|
|
311
|
+
</Button>
|
|
312
|
+
</div>)}
|
|
313
|
+
</div>
|
|
314
|
+
<div className={css.colorhsl(0, 0, 45)}>
|
|
315
|
+
version {String(consensus.version ?? "none")}, from {consensus.servers.length} server{consensus.servers.length === 1 && "" || "s"}
|
|
316
|
+
</div>
|
|
317
|
+
</div>
|
|
318
|
+
<div className={css.vbox(10).fillWidth.paddingLeft(GROUP_BODY_INDENT).boxSizing("border-box")}>
|
|
319
|
+
{conflicts.map((conflict, index) => <ConflictWarning key={index} consensus={consensus} conflict={conflict} />)}
|
|
320
|
+
{clusters.map((cluster, index) => {
|
|
321
|
+
let status = getWindowStatus(cluster.window, now);
|
|
322
|
+
return <div key={index} className={css.vbox(6).fillWidth}>
|
|
323
|
+
<div className={css.hbox(6).wrap.colorhsl(0, 0, 35).fontSize(TAG_FONT_SIZE)}>
|
|
324
|
+
<div>{formatWindowTime(cluster.window[0])} → {formatWindowTime(cluster.window[1])}</div>
|
|
325
|
+
<Tag icon={status.icon} text={status.text} color={status.color} />
|
|
326
|
+
</div>
|
|
327
|
+
{cluster.sources.map(source => <SourceRow
|
|
328
|
+
key={getSourceKey(source, ownBucketName)}
|
|
329
|
+
source={source}
|
|
330
|
+
window={cluster.window}
|
|
331
|
+
ownBucketName={ownBucketName}
|
|
332
|
+
/>)}
|
|
333
|
+
</div>;
|
|
334
|
+
})}
|
|
335
|
+
</div>
|
|
336
|
+
</div>;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export class RouteConfigView extends qreact.Component<{ servers: StorageServerBuckets[] }> {
|
|
341
|
+
render() {
|
|
342
|
+
let groups = getRouteConfigGroups(this.props.servers);
|
|
343
|
+
if (!groups.length) return undefined;
|
|
344
|
+
return <div className={css.vbox(20).fillWidth}>
|
|
345
|
+
<h3>Routing configs</h3>
|
|
346
|
+
{groups.map((group, index) => <RouteConfigGroupView key={index} group={group} />)}
|
|
347
|
+
</div>;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { SocketFunction } from "socket-function/SocketFunction";
|
|
2
|
+
import { qreact } from "../../4-dom/qreact";
|
|
3
|
+
import { css } from "typesafecss";
|
|
4
|
+
import { formatNumber } from "socket-function/src/formatting/format";
|
|
5
|
+
import { sort } from "socket-function/src/misc";
|
|
6
|
+
import { listServerBuckets } from "sliftutils/storage/remoteStorage/createArchives";
|
|
7
|
+
import type { ServerBucketInfo } from "sliftutils/storage/remoteStorage/storageServerState";
|
|
8
|
+
import { getSyncedController } from "../../library-components/SyncedController";
|
|
9
|
+
import { assertIsManagementUser } from "../../diagnostics/managementPages";
|
|
10
|
+
import { getEffectiveServiceConfigs, getLiveServiceParameters, getMachineTargets, applyCommandTemplate, ServiceParameters } from "../machineSchema";
|
|
11
|
+
import { STORAGE_ACCOUNT } from "../../-a-archives/archives2";
|
|
12
|
+
import { Table } from "../../5-diagnostics/Table";
|
|
13
|
+
import { RouteConfigView } from "./RouteConfigView";
|
|
14
|
+
|
|
15
|
+
module.hotreload = true;
|
|
16
|
+
|
|
17
|
+
const STORAGE_COMMAND_PREFIX = "yarn storageserve";
|
|
18
|
+
const URL_ARG_REGEX = /--url\s+(\S+)/g;
|
|
19
|
+
const ERROR_TEXT_LIMIT = 500;
|
|
20
|
+
|
|
21
|
+
export type StorageServerBuckets = {
|
|
22
|
+
serviceKey: string;
|
|
23
|
+
url: string;
|
|
24
|
+
buckets?: ServerBucketInfo[];
|
|
25
|
+
error?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function getStorageUrls(parameters: ServiceParameters): string[] {
|
|
29
|
+
if (!parameters.command.trimStart().startsWith(STORAGE_COMMAND_PREFIX)) return [];
|
|
30
|
+
let urls: string[] = [];
|
|
31
|
+
for (let target of getMachineTargets(parameters)) {
|
|
32
|
+
let command = applyCommandTemplate(parameters.command, target.variables);
|
|
33
|
+
for (let match of command.matchAll(URL_ARG_REGEX)) {
|
|
34
|
+
urls.push(match[1]);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return urls;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class StoragePageControllerBase {
|
|
41
|
+
public async getStorageBuckets(): Promise<StorageServerBuckets[]> {
|
|
42
|
+
let configs = await getEffectiveServiceConfigs();
|
|
43
|
+
let serviceKeyByUrl = new Map<string, string>();
|
|
44
|
+
for (let config of configs) {
|
|
45
|
+
let allParameters = [getLiveServiceParameters(config), config.parameters, config.oldParameters];
|
|
46
|
+
for (let parameters of allParameters) {
|
|
47
|
+
if (!parameters) continue;
|
|
48
|
+
for (let url of getStorageUrls(parameters)) {
|
|
49
|
+
if (serviceKeyByUrl.has(url)) continue;
|
|
50
|
+
serviceKeyByUrl.set(url, parameters.key);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return await Promise.all([...serviceKeyByUrl].map(async ([url, serviceKey]) => {
|
|
55
|
+
try {
|
|
56
|
+
return { serviceKey, url, buckets: await listServerBuckets({ url, account: STORAGE_ACCOUNT }) };
|
|
57
|
+
} catch (e) {
|
|
58
|
+
return { serviceKey, url, error: String((e as Error).stack ?? e).slice(0, ERROR_TEXT_LIMIT) };
|
|
59
|
+
}
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const StoragePageController = SocketFunction.register(
|
|
65
|
+
"StoragePageController-4f1c93ab-77e2-4d15-9a30-1c6b8f5d2e04",
|
|
66
|
+
new StoragePageControllerBase(),
|
|
67
|
+
() => ({
|
|
68
|
+
getStorageBuckets: {},
|
|
69
|
+
}),
|
|
70
|
+
() => ({
|
|
71
|
+
hooks: [assertIsManagementUser],
|
|
72
|
+
}),
|
|
73
|
+
{
|
|
74
|
+
}
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const StorageSynced = getSyncedController(StoragePageController, {
|
|
78
|
+
reads: {},
|
|
79
|
+
writes: {},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
type BucketConfig = NonNullable<ServerBucketInfo["config"]>;
|
|
83
|
+
|
|
84
|
+
type BucketRow = {
|
|
85
|
+
server: string;
|
|
86
|
+
bucket: string;
|
|
87
|
+
state: string;
|
|
88
|
+
files: string;
|
|
89
|
+
bytes: string;
|
|
90
|
+
indexSources: BucketConfig["indexSources"];
|
|
91
|
+
readerDiskLimit: string;
|
|
92
|
+
syncing: BucketConfig["syncing"];
|
|
93
|
+
error: string;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
function getBucketRows(servers: StorageServerBuckets[]): BucketRow[] {
|
|
97
|
+
let rows: BucketRow[] = [];
|
|
98
|
+
for (let server of servers) {
|
|
99
|
+
let baseRow: BucketRow = {
|
|
100
|
+
server: server.url,
|
|
101
|
+
bucket: "",
|
|
102
|
+
state: "",
|
|
103
|
+
files: "",
|
|
104
|
+
bytes: "",
|
|
105
|
+
indexSources: undefined,
|
|
106
|
+
readerDiskLimit: "",
|
|
107
|
+
syncing: undefined,
|
|
108
|
+
error: "",
|
|
109
|
+
};
|
|
110
|
+
if (server.error) {
|
|
111
|
+
rows.push({ ...baseRow, error: server.error });
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
let buckets = [...server.buckets || []];
|
|
115
|
+
sort(buckets, x => x.bucketName);
|
|
116
|
+
if (!buckets.length) {
|
|
117
|
+
rows.push({ ...baseRow, bucket: "No buckets" });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
for (let [index, bucket] of buckets.entries()) {
|
|
121
|
+
let config = bucket.config;
|
|
122
|
+
rows.push({
|
|
123
|
+
...baseRow,
|
|
124
|
+
server: index === 0 && server.url || "",
|
|
125
|
+
bucket: bucket.bucketName,
|
|
126
|
+
state: bucket.active && "active" || "inactive",
|
|
127
|
+
files: config?.index && formatNumber(config.index.fileCount) || "",
|
|
128
|
+
bytes: config?.index && formatNumber(config.index.byteCount) + "B" || "",
|
|
129
|
+
indexSources: config?.indexSources,
|
|
130
|
+
readerDiskLimit: config?.readerDiskLimit && formatNumber(config.readerDiskLimit) + "B" || "",
|
|
131
|
+
syncing: config?.syncing,
|
|
132
|
+
error: bucket.error || "",
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return rows;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const SOURCE_BAR_COLOR = { h: 210, s: 65, l: 55 };
|
|
140
|
+
const REMOTE_SOURCE_BAR_COLOR = { h: 0, s: 75, l: 55 };
|
|
141
|
+
const SOURCE_BAR_OPACITY = 0.3;
|
|
142
|
+
const SOURCE_ROW_PADDING = { horizontal: 4, vertical: 3 };
|
|
143
|
+
const DISK_SOURCE_TYPE = "disk";
|
|
144
|
+
const REMOTE_SOURCE_TOOLTIP = "Values stored on another server. If this other server goes down, we'll lose access to the data.";
|
|
145
|
+
const SOURCE_TYPE_COLOR = { h: 265, s: 40, l: 88 };
|
|
146
|
+
const SOURCE_PART_COLOR = { h: 0, s: 0, l: 96 };
|
|
147
|
+
const SOURCE_PART_FONT_SIZE = 11;
|
|
148
|
+
|
|
149
|
+
const DEBUG_NAME_PATTERNS: { regex: RegExp; parts: string[] }[] = [
|
|
150
|
+
{ regex: /^remoteStorage (\S+) account (\S+) bucket (.+)$/, parts: ["remoteStorage"] },
|
|
151
|
+
{ regex: /^localBucket account (\S+) bucket (.+)$/, parts: ["localBucket"] },
|
|
152
|
+
{ regex: /^backblaze (.+)$/, parts: ["backblaze"] },
|
|
153
|
+
{ regex: /^disk (.+)$/, parts: ["disk"] },
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
/** Splits an IArchives debug name into its type and the identifying pieces after it, so each piece can be boxed separately. */
|
|
157
|
+
function parseSourceDebugName(debugName: string): { type: string; parts: string[] } {
|
|
158
|
+
for (let pattern of DEBUG_NAME_PATTERNS) {
|
|
159
|
+
let match = pattern.regex.exec(debugName);
|
|
160
|
+
if (!match) continue;
|
|
161
|
+
return { type: pattern.parts[0], parts: match.slice(1) };
|
|
162
|
+
}
|
|
163
|
+
return { type: debugName, parts: [] };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
class SourceNameParts extends qreact.Component<{ debugName: string }> {
|
|
167
|
+
render() {
|
|
168
|
+
let { type, parts } = parseSourceDebugName(this.props.debugName);
|
|
169
|
+
let partStyle = css.pad2(5, 0).fontSize(SOURCE_PART_FONT_SIZE).whiteSpace("nowrap");
|
|
170
|
+
return <div className={css.hbox(3).wrap}>
|
|
171
|
+
<div className={partStyle.hsl(SOURCE_TYPE_COLOR.h, SOURCE_TYPE_COLOR.s, SOURCE_TYPE_COLOR.l).bord2(SOURCE_TYPE_COLOR.h, SOURCE_TYPE_COLOR.s, SOURCE_TYPE_COLOR.l - 20)}>
|
|
172
|
+
{type}
|
|
173
|
+
</div>
|
|
174
|
+
{parts.map(part => <div
|
|
175
|
+
key={part}
|
|
176
|
+
className={partStyle.hsl(SOURCE_PART_COLOR.h, SOURCE_PART_COLOR.s, SOURCE_PART_COLOR.l).bord2(SOURCE_PART_COLOR.h, SOURCE_PART_COLOR.s, SOURCE_PART_COLOR.l - 20)}
|
|
177
|
+
>
|
|
178
|
+
{part}
|
|
179
|
+
</div>)}
|
|
180
|
+
</div>;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
class IndexSourcesCell extends qreact.Component<{ sources: BucketConfig["indexSources"] }> {
|
|
185
|
+
render() {
|
|
186
|
+
let sources = [...this.props.sources || []];
|
|
187
|
+
if (!sources.length) return undefined;
|
|
188
|
+
sort(sources, x => -x.byteCount);
|
|
189
|
+
let totalBytes = sources.reduce((sum, x) => sum + x.byteCount, 0);
|
|
190
|
+
return <div className={css.vbox(2).fillWidth}>
|
|
191
|
+
{sources.map(source => {
|
|
192
|
+
let fraction = totalBytes && source.byteCount / totalBytes || 0;
|
|
193
|
+
// Only the server's own disk holds bytes we can't lose to another machine going down
|
|
194
|
+
let remote = parseSourceDebugName(source.debugName).type !== DISK_SOURCE_TYPE;
|
|
195
|
+
let barColor = remote && REMOTE_SOURCE_BAR_COLOR || SOURCE_BAR_COLOR;
|
|
196
|
+
return <div
|
|
197
|
+
key={source.debugName}
|
|
198
|
+
className={css.relative.fillWidth.pad2(SOURCE_ROW_PADDING.horizontal, SOURCE_ROW_PADDING.vertical)}
|
|
199
|
+
title={remote && REMOTE_SOURCE_TOOLTIP || undefined}
|
|
200
|
+
>
|
|
201
|
+
<div className={
|
|
202
|
+
css.absolute.top(0).left(0).size(`${fraction * 100}%`, "100%")
|
|
203
|
+
.hsla(barColor.h, barColor.s, barColor.l, SOURCE_BAR_OPACITY)
|
|
204
|
+
} />
|
|
205
|
+
<div className={css.relative.hbox(6).wrap}>
|
|
206
|
+
<div className={css.whiteSpace("nowrap")}>
|
|
207
|
+
{formatNumber(source.byteCount)}B, {formatNumber(source.fileCount)} files
|
|
208
|
+
</div>
|
|
209
|
+
<SourceNameParts debugName={source.debugName} />
|
|
210
|
+
</div>
|
|
211
|
+
</div>;
|
|
212
|
+
})}
|
|
213
|
+
</div>;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export class StoragePage extends qreact.Component {
|
|
218
|
+
render() {
|
|
219
|
+
let servers = StorageSynced(SocketFunction.browserNodeId()).getStorageBuckets();
|
|
220
|
+
if (!servers) return <div>Loading storage servers...</div>;
|
|
221
|
+
if (!servers.length) {
|
|
222
|
+
return <div className={css.vbox(16)}>
|
|
223
|
+
<h2>Storage</h2>
|
|
224
|
+
<div>No services with a command starting with {JSON.stringify(STORAGE_COMMAND_PREFIX)} were found.</div>
|
|
225
|
+
</div>;
|
|
226
|
+
}
|
|
227
|
+
return <div className={css.vbox(16)}>
|
|
228
|
+
<div className={css.hbox(12)}>
|
|
229
|
+
<h2 className={css.flexGrow(1)}>Storage</h2>
|
|
230
|
+
<button className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 0, 100)}
|
|
231
|
+
onClick={() => {
|
|
232
|
+
StorageSynced(SocketFunction.browserNodeId()).getStorageBuckets.resetAll();
|
|
233
|
+
}}>
|
|
234
|
+
Refresh
|
|
235
|
+
</button>
|
|
236
|
+
</div>
|
|
237
|
+
<Table
|
|
238
|
+
rows={getBucketRows(servers)}
|
|
239
|
+
columns={{
|
|
240
|
+
server: { title: "Server" },
|
|
241
|
+
bucket: { title: "Bucket" },
|
|
242
|
+
state: { title: "State" },
|
|
243
|
+
files: { title: "Files" },
|
|
244
|
+
bytes: { title: "Bytes" },
|
|
245
|
+
indexSources: {
|
|
246
|
+
title: "Index sources",
|
|
247
|
+
formatter: sources => <IndexSourcesCell sources={sources} />
|
|
248
|
+
},
|
|
249
|
+
readerDiskLimit: { title: "Read cache limit" },
|
|
250
|
+
syncing: {
|
|
251
|
+
title: "Syncing",
|
|
252
|
+
formatter: syncing => <div className={css.vbox(2)}>
|
|
253
|
+
{(syncing || []).map((activity, activityIndex) => <div key={activityIndex}>
|
|
254
|
+
{activity.type} {activity.sourceDebugName}
|
|
255
|
+
{activity.totalFiles !== undefined && ` (${formatNumber(activity.doneFiles || 0)}/${formatNumber(activity.totalFiles)} files)`}
|
|
256
|
+
</div>)}
|
|
257
|
+
</div>
|
|
258
|
+
},
|
|
259
|
+
error: { title: "Error" },
|
|
260
|
+
}}
|
|
261
|
+
/>
|
|
262
|
+
<RouteConfigView servers={servers} />
|
|
263
|
+
</div>;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { URLParam } from "../library-components/URLParam";
|
|
2
2
|
|
|
3
|
-
export type ViewType = "services" | "machines" | "service-detail" | "machine-detail" | "deploy";
|
|
3
|
+
export type ViewType = "services" | "machines" | "service-detail" | "machine-detail" | "deploy" | "storage";
|
|
4
4
|
|
|
5
5
|
export const currentViewParam = new URLParam<ViewType>("machineview", "machines");
|
|
6
6
|
export const selectedServiceIdParam = new URLParam("serviceId", "");
|
package/src/misc/random.ts
CHANGED