diff --git a/packages/client/src/databases/app/migrations/00001-create-servers-table.ts b/packages/client/src/databases/app/migrations/00001-create-servers-table.ts index fd739802..2962e003 100644 --- a/packages/client/src/databases/app/migrations/00001-create-servers-table.ts +++ b/packages/client/src/databases/app/migrations/00001-create-servers-table.ts @@ -21,7 +21,7 @@ export const createServersTable: Migration = { name: 'Colanode Cloud (EU)', avatar: 'https://colanode.com/assets/flags/eu.svg', attributes: '{}', - version: '0.1.0', + version: '0.2.0', created_at: new Date().toISOString(), }, { @@ -29,7 +29,7 @@ export const createServersTable: Migration = { name: 'Colanode Cloud (US)', avatar: 'https://colanode.com/assets/flags/us.svg', attributes: '{}', - version: '0.1.0', + version: '0.2.0', created_at: new Date().toISOString(), }, ]) diff --git a/packages/client/src/handlers/queries/servers/server-list.ts b/packages/client/src/handlers/queries/servers/server-list.ts index d194a13a..4eda4131 100644 --- a/packages/client/src/handlers/queries/servers/server-list.ts +++ b/packages/client/src/handlers/queries/servers/server-list.ts @@ -1,9 +1,8 @@ -import { SelectServer } from '@colanode/client/databases/app'; import { ChangeCheckResult, QueryHandler } from '@colanode/client/lib'; import { ServerListQueryInput } from '@colanode/client/queries/servers/server-list'; import { AppService } from '@colanode/client/services/app-service'; import { Event } from '@colanode/client/types/events'; -import { Server } from '@colanode/client/types/servers'; +import { ServerDetails } from '@colanode/client/types/servers'; export class ServerListQueryHandler implements QueryHandler @@ -14,37 +13,34 @@ export class ServerListQueryHandler this.app = app; } - async handleQuery(_: ServerListQueryInput): Promise { - const rows = await this.fetchServers(); - return this.mapServers(rows); + async handleQuery(_: ServerListQueryInput): Promise { + return this.getServers(); } async checkForChanges( event: Event, _: ServerListQueryInput, - output: Server[] + __: ServerDetails[] ): Promise> { if (event.type === 'server.created') { - const newServers = [...output, event.server]; return { hasChanges: true, - result: newServers, + result: this.getServers(), }; } else if (event.type === 'server.updated') { - const newServers = output.map((server) => - server.domain === event.server.domain ? event.server : server - ); return { hasChanges: true, - result: newServers, + result: this.getServers(), }; } else if (event.type === 'server.deleted') { - const newServers = output.filter( - (server) => server.domain !== event.server.domain - ); return { hasChanges: true, - result: newServers, + result: this.getServers(), + }; + } else if (event.type === 'server.availability.changed') { + return { + hasChanges: true, + result: this.getServers(), }; } @@ -53,21 +49,21 @@ export class ServerListQueryHandler }; } - private fetchServers(): Promise { - return this.app.database.selectFrom('servers').selectAll().execute(); - } + private getServers(): ServerDetails[] { + const serverServices = this.app.getServers(); + const result: ServerDetails[] = []; - private mapServers = (rows: SelectServer[]): Server[] => { - return rows.map((row) => { - return { - domain: row.domain, - name: row.name, - avatar: row.avatar, - attributes: JSON.parse(row.attributes), - version: row.version, - createdAt: new Date(row.created_at), - syncedAt: row.synced_at ? new Date(row.synced_at) : null, + for (const serverService of serverServices) { + const serverDetails: ServerDetails = { + ...serverService.server, + state: serverService.state, + isOutdated: serverService.isOutdated, + configUrl: serverService.configUrl, }; - }); - }; + + result.push(serverDetails); + } + + return result; + } } diff --git a/packages/client/src/queries/servers/server-list.ts b/packages/client/src/queries/servers/server-list.ts index f1aa0044..0042e8dc 100644 --- a/packages/client/src/queries/servers/server-list.ts +++ b/packages/client/src/queries/servers/server-list.ts @@ -1,4 +1,4 @@ -import { Server } from '@colanode/client/types/servers'; +import { ServerDetails } from '@colanode/client/types/servers'; export type ServerListQueryInput = { type: 'server.list'; @@ -8,7 +8,7 @@ declare module '@colanode/client/queries' { interface QueryMap { 'server.list': { input: ServerListQueryInput; - output: Server[]; + output: ServerDetails[]; }; } } diff --git a/packages/client/src/services/app-service.ts b/packages/client/src/services/app-service.ts index 9a86d31d..01e2fc64 100644 --- a/packages/client/src/services/app-service.ts +++ b/packages/client/src/services/app-service.ts @@ -117,6 +117,10 @@ export class AppService { return Array.from(this.accounts.values()); } + public getServers(): ServerService[] { + return Array.from(this.servers.values()); + } + public getServer(domain: string): ServerService | null { return this.servers.get(domain) ?? null; } @@ -191,6 +195,7 @@ export class AppService { } const attributes: ServerAttributes = { + sha: config.sha, pathPrefix: config.pathPrefix, insecure: url.protocol === 'http:', account: config.account?.google.enabled diff --git a/packages/client/src/services/server-service.ts b/packages/client/src/services/server-service.ts index 5aceed4a..73f1fe93 100644 --- a/packages/client/src/services/server-service.ts +++ b/packages/client/src/services/server-service.ts @@ -6,24 +6,21 @@ import { EventLoop } from '@colanode/client/lib/event-loop'; import { mapServer } from '@colanode/client/lib/mappers'; import { isServerOutdated } from '@colanode/client/lib/servers'; import { AppService } from '@colanode/client/services/app-service'; -import { Server, ServerAttributes } from '@colanode/client/types/servers'; +import { + Server, + ServerAttributes, + ServerState, +} from '@colanode/client/types/servers'; import { createDebugger, ServerConfig } from '@colanode/core'; -type ServerState = { - isAvailable: boolean; - lastCheckedAt: Date; - lastCheckedSuccessfullyAt: Date | null; - count: number; -}; - const debug = createDebugger('desktop:service:server'); export class ServerService { private readonly app: AppService; + private readonly eventLoop: EventLoop; - private state: ServerState | null = null; - private eventLoop: EventLoop; - private isOutdated: boolean; + public state: ServerState | null = null; + public isOutdated: boolean; public readonly server: Server; public readonly configUrl: string; @@ -88,6 +85,7 @@ export class ServerService { if (config) { const attributes: ServerAttributes = { ...this.server.attributes, + sha: config.sha, account: config.account?.google.enabled ? { google: { diff --git a/packages/client/src/types/servers.ts b/packages/client/src/types/servers.ts index 4cebad7f..b705cb2e 100644 --- a/packages/client/src/types/servers.ts +++ b/packages/client/src/types/servers.ts @@ -9,6 +9,14 @@ export type ServerAttributes = { pathPrefix?: string | null; insecure?: boolean; account?: ServerAccountAttributes; + sha?: string | null; +}; + +export type ServerState = { + isAvailable: boolean; + lastCheckedAt: Date; + lastCheckedSuccessfullyAt: Date | null; + count: number; }; export type Server = { @@ -20,3 +28,9 @@ export type Server = { createdAt: Date; syncedAt: Date | null; }; + +export type ServerDetails = Server & { + state: ServerState | null; + isOutdated: boolean; + configUrl: string; +}; diff --git a/packages/ui/src/components/layouts/layout.tsx b/packages/ui/src/components/layouts/layout.tsx index 2626bf79..25195862 100644 --- a/packages/ui/src/components/layouts/layout.tsx +++ b/packages/ui/src/components/layouts/layout.tsx @@ -3,12 +3,15 @@ import { Resizable } from 're-resizable'; import { ContainerBlank } from '@colanode/ui/components/layouts/containers/container-blank'; import { ContainerTabs } from '@colanode/ui/components/layouts/containers/container-tabs'; import { Sidebar } from '@colanode/ui/components/layouts/sidebars/sidebar'; +import { ServerUpgradeRequired } from '@colanode/ui/components/servers/server-upgrade-required'; import { LayoutContext } from '@colanode/ui/contexts/layout'; +import { useServer } from '@colanode/ui/contexts/server'; import { useLayoutState } from '@colanode/ui/hooks/use-layout-state'; import { useWindowSize } from '@colanode/ui/hooks/use-window-size'; import { percentToNumber } from '@colanode/ui/lib/utils'; export const Layout = () => { + const server = useServer(); const windowSize = useWindowSize(); const { @@ -35,10 +38,11 @@ export const Layout = () => { handleMoveRight, } = useLayoutState(); - const shouldDisplayLeft = leftContainerMetadata.tabs.length > 0; + const shouldDisplayLeft = + !server.isOutdated && leftContainerMetadata.tabs.length > 0; const shouldDisplayRight = - shouldDisplayLeft && rightContainerMetadata.tabs.length > 0; + !server.isOutdated && rightContainerMetadata.tabs.length > 0; return ( { /> )} - {!shouldDisplayLeft && !shouldDisplayRight && } + {!server.isOutdated && !shouldDisplayLeft && !shouldDisplayRight && ( + + )} + {server.isOutdated && } ); diff --git a/packages/ui/src/components/servers/server-delete-dialog.tsx b/packages/ui/src/components/servers/server-delete-dialog.tsx index 45a56c72..92c959e5 100644 --- a/packages/ui/src/components/servers/server-delete-dialog.tsx +++ b/packages/ui/src/components/servers/server-delete-dialog.tsx @@ -1,5 +1,6 @@ import { toast } from 'sonner'; +import { ServerDetails } from '@colanode/client/types'; import { AlertDialog, AlertDialogCancel, @@ -16,11 +17,11 @@ import { useMutation } from '@colanode/ui/hooks/use-mutation'; interface ServerDeleteDialogProps { open: boolean; onOpenChange: (open: boolean) => void; - domain: string; + server: ServerDetails; } export const ServerDeleteDialog = ({ - domain, + server, open, onOpenChange, }: ServerDeleteDialogProps) => { @@ -32,7 +33,7 @@ export const ServerDeleteDialog = ({ Are you sure you want delete the server{' '} - "{domain}"? + "{server.domain}"? Deleting the server will remove all accounts connected to it. You @@ -48,7 +49,7 @@ export const ServerDeleteDialog = ({ mutate({ input: { type: 'server.delete', - domain, + domain: server.domain, }, onSuccess() { onOpenChange(false); diff --git a/packages/ui/src/components/servers/server-dropdown.tsx b/packages/ui/src/components/servers/server-dropdown.tsx index 94c9397e..96675f55 100644 --- a/packages/ui/src/components/servers/server-dropdown.tsx +++ b/packages/ui/src/components/servers/server-dropdown.tsx @@ -1,11 +1,16 @@ -import { ChevronDown, PlusIcon, ServerOffIcon, TrashIcon } from 'lucide-react'; +import { + ChevronDown, + PlusIcon, + ServerOffIcon, + SettingsIcon, +} from 'lucide-react'; import { Fragment, useState } from 'react'; -import { Server } from '@colanode/client/types'; -import { isColanodeServer } from '@colanode/core'; +import { Server, ServerDetails } from '@colanode/client/types'; import { ServerAvatar } from '@colanode/ui/components/servers/server-avatar'; 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'; import { DropdownMenu, DropdownMenuContent, @@ -17,7 +22,7 @@ import { interface ServerDropdownProps { value: Server | null; onChange: (server: Server) => void; - servers: Server[]; + servers: ServerDetails[]; readonly?: boolean; } @@ -29,8 +34,14 @@ export const ServerDropdown = ({ }: ServerDropdownProps) => { const [open, setOpen] = useState(false); const [openCreate, setOpenCreate] = useState(false); + const [settingsDomain, setSettingsDomain] = useState(null); const [deleteDomain, setDeleteDomain] = useState(null); + const settingsServer = servers.find( + (server) => server.domain === settingsDomain + ); + const deleteServer = servers.find((server) => server.domain === deleteDomain); + return ( - {servers.map((server) => { - const canDelete = !isColanodeServer(server.domain); - return ( - { - if (value?.domain !== server.domain) { - onChange(server); - } - }} - className="group/server flex w-full flex-grow flex-row items-center gap-3 rounded-md border-b border-input p-2 cursor-pointer hover:bg-gray-100" - > -
- -
-

{server.name}

-

- {server.domain} -

-
+ {servers.map((server) => ( + { + if (value?.domain !== server.domain) { + onChange(server); + } + }} + className="group/server flex w-full flex-grow flex-row items-center gap-3 rounded-md border-b border-input p-2 cursor-pointer hover:bg-gray-100" + > +
+ +
+

{server.name}

+

+ {server.domain} +

- {canDelete && ( - - )} - - ); - })} +
+ +
+ ))} { @@ -130,10 +136,10 @@ export const ServerDropdown = ({ }} /> )} - {deleteDomain && ( + {deleteServer && ( { if (!open) { setDeleteDomain(null); @@ -141,6 +147,21 @@ export const ServerDropdown = ({ }} /> )} + {settingsServer && ( + { + if (!open) { + setSettingsDomain(null); + } + }} + onDelete={() => { + setSettingsDomain(null); + setDeleteDomain(settingsServer.domain); + }} + /> + )} ); }; diff --git a/packages/ui/src/components/servers/server-provider.tsx b/packages/ui/src/components/servers/server-provider.tsx index 090710df..87859f46 100644 --- a/packages/ui/src/components/servers/server-provider.tsx +++ b/packages/ui/src/components/servers/server-provider.tsx @@ -1,6 +1,4 @@ -import { isServerOutdated } from '@colanode/client/lib'; import { ServerNotFound } from '@colanode/ui/components/servers/server-not-found'; -import { ServerUpgradeRequired } from '@colanode/ui/components/servers/server-upgrade-required'; import { ServerContext } from '@colanode/ui/contexts/server'; import { useQuery } from '@colanode/ui/hooks/use-query'; import { isFeatureSupported } from '@colanode/ui/lib/features'; @@ -27,8 +25,6 @@ export const ServerProvider = ({ domain, children }: ServerProviderProps) => { return ; } - const isOutdated = isServerOutdated(server.version); - return ( { }, }} > - {isOutdated ? : children} + {children} ); }; diff --git a/packages/ui/src/components/servers/server-settings-dialog.tsx b/packages/ui/src/components/servers/server-settings-dialog.tsx new file mode 100644 index 00000000..454fb265 --- /dev/null +++ b/packages/ui/src/components/servers/server-settings-dialog.tsx @@ -0,0 +1,191 @@ +import { TrashIcon } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +import { ServerDetails } from '@colanode/client/types'; +import { formatDate, isColanodeServer, 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'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@colanode/ui/components/ui/dialog'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@colanode/ui/components/ui/tooltip'; +import { cn } from '@colanode/ui/lib/utils'; + +interface ServerSettingsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + server: ServerDetails; + onDelete: () => void; +} + +export const ServerSettingsDialog = ({ + open, + onOpenChange, + server, + onDelete, +}: ServerSettingsDialogProps) => { + const [, setTick] = useState(0); + + useEffect(() => { + if (!open) return; + + const interval = setInterval(() => { + setTick((prev) => prev + 1); + }, 1000); + + return () => clearInterval(interval); + }, [open]); + + const canDelete = !isColanodeServer(server.domain); + const isAvailable = server.state?.isAvailable ?? false; + const isOutdated = server.isOutdated; + + return ( + + + +
+ +
+ {server.name} + + {server.domain} + +
+
+
+ +
+
+

Server Status

+
+
+ Status + + + + {server.state?.isAvailable ? 'Available' : 'Unavailable'} + + + + {isAvailable + ? 'Server is available to use in this device' + : 'Server is unavailable to use. This means the server is not reachable, is outdated or is not allowed to be used from this app.'} + + +
+
+ Version +
+ {server.version} + {isOutdated && ( + + + + Outdated + + + + Server is outdated. Please update the server to the + latest version. + + + )} +
+
+
+ SHA + {server.attributes.sha} +
+
+ Last ping + + {server.state?.lastCheckedAt + ? timeAgo(server.state?.lastCheckedAt) + : 'Never'} + +
+ {server.state?.lastCheckedSuccessfullyAt && + server.state?.lastCheckedAt && + server.state.lastCheckedSuccessfullyAt.getTime() !== + server.state.lastCheckedAt.getTime() && ( +
+ + Last successful ping + + + {timeAgo(server.state.lastCheckedSuccessfullyAt)} + +
+ )} +
+
+ +
+

Server Details

+
+
+ Domain + {server.domain} +
+
+ Created + {formatDate(server.createdAt)} +
+
+ + Config URL + + + {server.configUrl} + +
+
+
+
+ + {canDelete && ( +
+

Delete server from this device

+ +
+ )} +
+
+ ); +}; diff --git a/packages/ui/src/components/servers/server-upgrade-required.tsx b/packages/ui/src/components/servers/server-upgrade-required.tsx index 63528f67..589b3e59 100644 --- a/packages/ui/src/components/servers/server-upgrade-required.tsx +++ b/packages/ui/src/components/servers/server-upgrade-required.tsx @@ -6,7 +6,7 @@ export const ServerUpgradeRequired = () => { const server = useServer(); return ( -
+

Server upgrade required

diff --git a/packages/ui/src/contexts/server.ts b/packages/ui/src/contexts/server.ts index 4fca3bd7..54cb3a2a 100644 --- a/packages/ui/src/contexts/server.ts +++ b/packages/ui/src/contexts/server.ts @@ -1,9 +1,9 @@ import { createContext, useContext } from 'react'; -import { Server } from '@colanode/client/types'; +import { ServerDetails } from '@colanode/client/types'; import { FeatureKey } from '@colanode/ui/lib/features'; -interface ServerContext extends Server { +interface ServerContext extends ServerDetails { supports(feature: FeatureKey): boolean; }