Improve server loading and syncing (#253)

This commit is contained in:
Hakan Shehu
2025-11-14 07:50:19 -08:00
committed by GitHub
parent cff19b1c5b
commit eee31d61bd
14 changed files with 198 additions and 70 deletions

View File

@@ -186,6 +186,10 @@ const initApp = async (): Promise<AppInitOutput> => {
await app.metadata.delete('app', 'theme.mode');
}
// add default Colanode servers
await app.createServer(new URL('https://eu.colanode.com/config'));
await app.createServer(new URL('https://us.colanode.com/config'));
return 'success';
};

View File

@@ -9,7 +9,13 @@ import {
import { QueryInput, QueryMap } from '@colanode/client/queries';
import { AppMeta, AppService } from '@colanode/client/services';
import { AppInitOutput } from '@colanode/client/types';
import { build, extractFileSubtype, generateId, IdType } from '@colanode/core';
import {
build,
extractFileSubtype,
generateId,
IdType,
isColanodeDomain,
} from '@colanode/core';
import {
BroadcastInitMessage,
BroadcastMessage,
@@ -72,6 +78,12 @@ navigator.locks.request('colanode', async () => {
await app.metadata.set('app', 'version', build.version);
await app.metadata.set('app', 'platform', appMeta.platform);
const domain = self.location.hostname;
if (isColanodeDomain(domain)) {
await app.createServer(new URL('https://eu.colanode.com/config'));
await app.createServer(new URL('https://us.colanode.com/config'));
}
appInitOutput = 'success';
broadcastMessage({

View File

@@ -62,6 +62,7 @@ import { RecordFieldValueSetMutationHandler } from './records/record-field-value
import { RecordNameUpdateMutationHandler } from './records/record-name-update';
import { ServerCreateMutationHandler } from './servers/server-create';
import { ServerDeleteMutationHandler } from './servers/server-delete';
import { ServerSyncMutationHandler } from './servers/server-sync';
import { SpaceChildReorderMutationHandler } from './spaces/space-child-reorder';
import { SpaceCreateMutationHandler } from './spaces/space-create';
import { SpaceDeleteMutationHandler } from './spaces/space-delete';
@@ -121,6 +122,7 @@ export const buildMutationHandlerMap = (
'select.option.update': new SelectOptionUpdateMutationHandler(app),
'server.create': new ServerCreateMutationHandler(app),
'server.delete': new ServerDeleteMutationHandler(app),
'server.sync': new ServerSyncMutationHandler(app),
'space.create': new SpaceCreateMutationHandler(app),
'space.delete': new SpaceDeleteMutationHandler(app),
'user.role.update': new UserRoleUpdateMutationHandler(app),

View File

@@ -5,7 +5,7 @@ import {
ServerDeleteMutationOutput,
} from '@colanode/client/mutations/servers/server-delete';
import { AppService } from '@colanode/client/services/app-service';
import { isColanodeServer } from '@colanode/core';
import { isColanodeDomain } from '@colanode/core';
export class ServerDeleteMutationHandler
implements MutationHandler<ServerDeleteMutationInput>
@@ -19,7 +19,7 @@ export class ServerDeleteMutationHandler
async handleMutation(
input: ServerDeleteMutationInput
): Promise<ServerDeleteMutationOutput> {
if (isColanodeServer(input.domain)) {
if (isColanodeDomain(input.domain)) {
throw new MutationError(
MutationErrorCode.ServerDeleteForbidden,
'Cannot delete Colanode server'

View File

@@ -0,0 +1,44 @@
import ms from 'ms';
import { MutationHandler } from '@colanode/client/lib/types';
import { MutationError, MutationErrorCode } from '@colanode/client/mutations';
import {
ServerSyncMutationInput,
ServerSyncMutationOutput,
} from '@colanode/client/mutations/servers/server-sync';
import { AppService } from '@colanode/client/services/app-service';
export class ServerSyncMutationHandler
implements MutationHandler<ServerSyncMutationInput>
{
private readonly app: AppService;
constructor(app: AppService) {
this.app = app;
}
async handleMutation(
input: ServerSyncMutationInput
): Promise<ServerSyncMutationOutput> {
const server = this.app.getServer(input.domain);
if (!server) {
throw new MutationError(
MutationErrorCode.ServerNotFound,
`Server ${input.domain} was not found! Try using a different server.`
);
}
// no need to sync if the server has been synced in the last minute
const lastSyncedAt = server.server.syncedAt;
if (lastSyncedAt && lastSyncedAt.getTime() > Date.now() - ms('1 minute')) {
return {
success: true,
};
}
const success = await server.sync();
return {
success,
};
}
}

View File

@@ -1,3 +1,5 @@
import ms from 'ms';
import {
JobHandler,
JobOutput,
@@ -38,6 +40,20 @@ export class ServerSyncJobHandler implements JobHandler<ServerSyncInput> {
};
}
const accounts = this.app
.getAccounts()
.filter((account) => account.server.domain === server.domain);
if (accounts.length === 0) {
// don't sync if the server has no active accounts and has been synced in the last day
const lastSyncedAt = server.server.syncedAt;
if (lastSyncedAt && lastSyncedAt.getTime() > Date.now() - ms('1 day')) {
return {
type: 'success',
};
}
}
await server.sync();
return {
type: 'success',

View File

@@ -67,6 +67,7 @@ export * from './users/users-create';
export * from './files/temp-file-create';
export * from './apps/tab-create';
export * from './apps/tab-update';
export * from './servers/server-sync';
export * from './apps/tab-delete';
// eslint-disable-next-line @typescript-eslint/no-empty-object-type

View File

@@ -0,0 +1,17 @@
export type ServerSyncMutationInput = {
type: 'server.sync';
domain: string;
};
export type ServerSyncMutationOutput = {
success: boolean;
};
declare module '@colanode/client/mutations' {
interface MutationMap {
'server.sync': {
input: ServerSyncMutationInput;
output: ServerSyncMutationOutput;
};
}
}

View File

@@ -97,7 +97,7 @@ export class ServerService {
await this.app.jobs.triggerJobSchedule(scheduleId);
}
public async sync() {
public async sync(): Promise<boolean> {
const config = await ServerService.fetchServerConfig(this.configUrl);
if (config) {
const attributes: ServerAttributes = {
@@ -158,6 +158,8 @@ export class ServerService {
type: 'server.updated',
server: this.server,
});
return isAvailable;
}
public static async fetchServerConfig(configUrl: URL | string) {

View File

@@ -1,3 +1,3 @@
export const isColanodeServer = (domain: string) => {
export const isColanodeDomain = (domain: string) => {
return domain.endsWith('.colanode.com');
};

View File

@@ -1,13 +1,11 @@
import { useLiveQuery } from '@tanstack/react-db';
import { PlusIcon, SettingsIcon } from 'lucide-react';
import { PlusIcon } from 'lucide-react';
import { useState } from 'react';
import { Server } from '@colanode/client/types';
import { collections } from '@colanode/ui/collections';
import { ServerAvatar } from '@colanode/ui/components/servers/server-avatar';
import { ServerCard } from '@colanode/ui/components/servers/server-card';
import { ServerCreateDialog } from '@colanode/ui/components/servers/server-create-dialog';
import { ServerDeleteDialog } from '@colanode/ui/components/servers/server-delete-dialog';
import { ServerSettingsDialog } from '@colanode/ui/components/servers/server-settings-dialog';
interface AuthServerProps {
onSelect: (server: Server) => void;
@@ -15,16 +13,11 @@ interface AuthServerProps {
export const AuthServer = ({ onSelect }: AuthServerProps) => {
const [openCreate, setOpenCreate] = useState(false);
const [settingsDomain, setSettingsDomain] = useState<string | null>(null);
const [deleteDomain, setDeleteDomain] = useState<string | null>(null);
const serversQuery = useLiveQuery((q) =>
q.from({ servers: collections.servers })
);
const servers = serversQuery.data ?? [];
const settingsServer = servers.find(
(server) => server.domain === settingsDomain
);
const deleteServer = servers.find((server) => server.domain === deleteDomain);
return (
<div className="flex flex-col gap-6 w-full">
@@ -40,31 +33,7 @@ export const AuthServer = ({ onSelect }: AuthServerProps) => {
</div>
<div className="flex flex-col gap-4">
{servers.map((server) => (
<button
key={server.domain}
onClick={() => onSelect(server)}
className="group/server relative flex w-full flex-row items-center gap-3 rounded-lg border border-border/60 bg-background p-2 text-left transition-all hover:cursor-pointer hover:border-border hover:bg-accent hover:shadow-md"
>
<ServerAvatar
url={server.avatar}
name={server.name}
className="size-8 rounded-md"
/>
<div className="grow">
<p className="grow font-semibold">{server.name}</p>
<p className="text-xs text-muted-foreground">{server.domain}</p>
</div>
<button
className="text-muted-foreground opacity-0 group-hover/server:opacity-100 hover:bg-input size-8 flex items-center justify-center rounded-md cursor-pointer"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setSettingsDomain(server.domain);
}}
>
<SettingsIcon className="size-4" />
</button>
</button>
<ServerCard key={server.domain} server={server} onSelect={onSelect} />
))}
<button
onClick={() => setOpenCreate(true)}
@@ -83,32 +52,6 @@ export const AuthServer = ({ onSelect }: AuthServerProps) => {
{openCreate && (
<ServerCreateDialog onCancel={() => setOpenCreate(false)} />
)}
{deleteServer && (
<ServerDeleteDialog
server={deleteServer}
open={!!deleteServer}
onOpenChange={(open) => {
if (!open) {
setDeleteDomain(null);
}
}}
/>
)}
{settingsServer && (
<ServerSettingsDialog
server={settingsServer}
open={!!settingsServer}
onOpenChange={(open) => {
if (!open) {
setSettingsDomain(null);
}
}}
onDelete={() => {
setSettingsDomain(null);
setDeleteDomain(settingsServer.domain);
}}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,87 @@
import { SettingsIcon } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import { Server } from '@colanode/client/types';
import { ServerAvatar } from '@colanode/ui/components/servers/server-avatar';
import { ServerDeleteDialog } from '@colanode/ui/components/servers/server-delete-dialog';
import { ServerSettingsDialog } from '@colanode/ui/components/servers/server-settings-dialog';
import { Spinner } from '@colanode/ui/components/ui/spinner';
import { useMutation } from '@colanode/ui/hooks/use-mutation';
interface ServerCardProps {
server: Server;
onSelect: (server: Server) => void;
}
export const ServerCard = ({ server, onSelect }: ServerCardProps) => {
const [openSettings, setOpenSettings] = useState(false);
const [openDelete, setOpenDelete] = useState(false);
const { mutate: syncServer, isPending: isSyncing } = useMutation();
const handleServerClick = () => {
if (isSyncing) return;
syncServer({
input: {
type: 'server.sync',
domain: server.domain,
},
onSuccess() {
onSelect(server);
},
onError(error) {
toast.error(error.message);
},
});
};
return (
<>
<button
onClick={handleServerClick}
disabled={isSyncing}
className="group/server relative flex w-full flex-row items-center gap-3 rounded-lg border border-border/60 bg-background p-2 text-left transition-all hover:cursor-pointer hover:border-border hover:bg-accent hover:shadow-md disabled:opacity-50 disabled:cursor-not-allowed"
>
<ServerAvatar
url={server.avatar}
name={server.name}
className="size-8 rounded-md"
/>
<div className="grow">
<p className="grow font-semibold">{server.name}</p>
<p className="text-xs text-muted-foreground">{server.domain}</p>
</div>
<button
className="text-muted-foreground opacity-0 group-hover/server:opacity-100 hover:bg-input size-8 flex items-center justify-center rounded-md cursor-pointer disabled:opacity-100"
disabled={isSyncing}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setOpenSettings(true);
}}
>
{isSyncing ? (
<Spinner className="size-4" />
) : (
<SettingsIcon className="size-4" />
)}
</button>
</button>
<ServerSettingsDialog
server={server}
open={openSettings}
onOpenChange={setOpenSettings}
onDelete={() => {
setOpenSettings(false);
setOpenDelete(true);
}}
/>
<ServerDeleteDialog
server={server}
open={openDelete}
onOpenChange={setOpenDelete}
/>
</>
);
};

View File

@@ -2,7 +2,7 @@ import { TrashIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Server } from '@colanode/client/types';
import { formatDate, isColanodeServer, timeAgo } from '@colanode/core';
import { formatDate, isColanodeDomain, timeAgo } from '@colanode/core';
import { ServerAvatar } from '@colanode/ui/components/servers/server-avatar';
import { Badge } from '@colanode/ui/components/ui/badge';
import { Button } from '@colanode/ui/components/ui/button';
@@ -45,7 +45,7 @@ export const ServerSettingsDialog = ({
return () => clearInterval(interval);
}, [open]);
const canDelete = !isColanodeServer(server.domain);
const canDelete = !isColanodeDomain(server.domain);
const isAvailable = server.state?.isAvailable ?? false;
const isOutdated = server.isOutdated;

View File

@@ -1,6 +1,6 @@
import { Cloud, ExternalLink } from 'lucide-react';
import { isColanodeServer } from '@colanode/core';
import { isColanodeDomain } from '@colanode/core';
import { Button } from '@colanode/ui/components/ui/button';
import { useServer } from '@colanode/ui/contexts/server';
@@ -8,7 +8,7 @@ const CLOUD_URL = 'https://cloud.colanode.com';
export const WorkspaceStorageCloud = () => {
const server = useServer();
if (!isColanodeServer(server.domain)) {
if (!isColanodeDomain(server.domain)) {
return null;
}