Server settings dialog and other minor improvements (#58)

This commit is contained in:
Hakan Shehu
2025-06-12 23:56:23 +02:00
committed by GitHub
parent 756af021d1
commit 10ac2d3019
13 changed files with 336 additions and 107 deletions

View File

@@ -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(),
},
])

View File

@@ -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<ServerListQueryInput>
@@ -14,37 +13,34 @@ export class ServerListQueryHandler
this.app = app;
}
async handleQuery(_: ServerListQueryInput): Promise<Server[]> {
const rows = await this.fetchServers();
return this.mapServers(rows);
async handleQuery(_: ServerListQueryInput): Promise<ServerDetails[]> {
return this.getServers();
}
async checkForChanges(
event: Event,
_: ServerListQueryInput,
output: Server[]
__: ServerDetails[]
): Promise<ChangeCheckResult<ServerListQueryInput>> {
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<SelectServer[]> {
return this.app.database.selectFrom('servers').selectAll().execute();
private getServers(): ServerDetails[] {
const serverServices = this.app.getServers();
const result: ServerDetails[] = [];
for (const serverService of serverServices) {
const serverDetails: ServerDetails = {
...serverService.server,
state: serverService.state,
isOutdated: serverService.isOutdated,
configUrl: serverService.configUrl,
};
result.push(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,
};
});
};
return result;
}
}

View File

@@ -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[];
};
}
}

View File

@@ -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

View File

@@ -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: {

View File

@@ -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;
};

View File

@@ -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 (
<LayoutContext.Provider
@@ -150,7 +154,10 @@ export const Layout = () => {
/>
</Resizable>
)}
{!shouldDisplayLeft && !shouldDisplayRight && <ContainerBlank />}
{!server.isOutdated && !shouldDisplayLeft && !shouldDisplayRight && (
<ContainerBlank />
)}
{server.isOutdated && <ServerUpgradeRequired />}
</div>
</LayoutContext.Provider>
);

View File

@@ -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 = ({
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure you want delete the server{' '}
<span className="font-bold">&quot;{domain}&quot;</span>?
<span className="font-bold">&quot;{server.domain}&quot;</span>?
</AlertDialogTitle>
<AlertDialogDescription>
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);

View File

@@ -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<string | null>(null);
const [deleteDomain, setDeleteDomain] = useState<string | null>(null);
const settingsServer = servers.find(
(server) => server.domain === settingsDomain
);
const deleteServer = servers.find((server) => server.domain === deleteDomain);
return (
<Fragment>
<DropdownMenu
@@ -70,9 +81,7 @@ export const ServerDropdown = ({
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-96">
{servers.map((server) => {
const canDelete = !isColanodeServer(server.domain);
return (
{servers.map((server) => (
<DropdownMenuItem
key={server.domain}
onSelect={() => {
@@ -95,21 +104,18 @@ export const ServerDropdown = ({
</p>
</div>
</div>
{canDelete && (
<button
className="text-muted-foreground opacity-0 group-hover/server:opacity-100 hover:bg-gray-200 size-8 flex items-center justify-center rounded-md cursor-pointer"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setDeleteDomain(server.domain);
setSettingsDomain(server.domain);
}}
>
<TrashIcon className="size-4" />
<SettingsIcon className="size-4" />
</button>
)}
</DropdownMenuItem>
);
})}
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
@@ -130,10 +136,10 @@ export const ServerDropdown = ({
}}
/>
)}
{deleteDomain && (
{deleteServer && (
<ServerDeleteDialog
domain={deleteDomain}
open={!!deleteDomain}
server={deleteServer}
open={!!deleteServer}
onOpenChange={(open) => {
if (!open) {
setDeleteDomain(null);
@@ -141,6 +147,21 @@ export const ServerDropdown = ({
}}
/>
)}
{settingsServer && (
<ServerSettingsDialog
server={settingsServer}
open={!!settingsServer}
onOpenChange={(open) => {
if (!open) {
setSettingsDomain(null);
}
}}
onDelete={() => {
setSettingsDomain(null);
setDeleteDomain(settingsServer.domain);
}}
/>
)}
</Fragment>
);
};

View File

@@ -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 <ServerNotFound domain={domain} />;
}
const isOutdated = isServerOutdated(server.version);
return (
<ServerContext.Provider
value={{
@@ -38,7 +34,7 @@ export const ServerProvider = ({ domain, children }: ServerProviderProps) => {
},
}}
>
{isOutdated ? <ServerUpgradeRequired /> : children}
{children}
</ServerContext.Provider>
);
};

View File

@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xl w-2xl min-w-xl overflow-y-auto">
<DialogHeader>
<div className="flex items-center gap-3">
<ServerAvatar
url={server.avatar}
name={server.name}
className="size-10"
/>
<div>
<DialogTitle className="text-left">{server.name}</DialogTitle>
<DialogDescription className="text-left">
{server.domain}
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="space-y-6">
<div className="border rounded-lg p-4">
<h3 className="text-sm font-semibold mb-3">Server Status</h3>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Status</span>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<Badge
variant="default"
className={cn(
'cursor-pointer select-none',
isAvailable ? 'bg-emerald-500' : 'bg-destructive'
)}
>
{server.state?.isAvailable ? 'Available' : 'Unavailable'}
</Badge>
</TooltipTrigger>
<TooltipContent side="top">
{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.'}
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Version</span>
<div className="flex items-center gap-2">
<span className="text-sm">{server.version}</span>
{isOutdated && (
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<Badge
variant="destructive"
className="text-xs cursor-pointer select-none"
>
Outdated
</Badge>
</TooltipTrigger>
<TooltipContent side="top">
Server is outdated. Please update the server to the
latest version.
</TooltipContent>
</Tooltip>
)}
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">SHA</span>
<span className="text-sm">{server.attributes.sha}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Last ping</span>
<span className="text-sm">
{server.state?.lastCheckedAt
? timeAgo(server.state?.lastCheckedAt)
: 'Never'}
</span>
</div>
{server.state?.lastCheckedSuccessfullyAt &&
server.state?.lastCheckedAt &&
server.state.lastCheckedSuccessfullyAt.getTime() !==
server.state.lastCheckedAt.getTime() && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Last successful ping
</span>
<span className="text-sm">
{timeAgo(server.state.lastCheckedSuccessfullyAt)}
</span>
</div>
)}
</div>
</div>
<div className="border rounded-lg p-4">
<h3 className="text-sm font-semibold mb-3">Server Details</h3>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Domain</span>
<span className="text-sm font-mono">{server.domain}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Created</span>
<span className="text-sm">{formatDate(server.createdAt)}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Config URL
</span>
<a
href={server.configUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline font-mono break-all"
>
{server.configUrl}
</a>
</div>
</div>
</div>
</div>
{canDelete && (
<div className="border rounded-lg p-4">
<h3 className="text-sm mb-3">Delete server from this device</h3>
<Button
variant="destructive"
onClick={() => {
onDelete();
}}
>
<TrashIcon className="size-4 mr-1" />
Delete
</Button>
</div>
)}
</DialogContent>
</Dialog>
);
};

View File

@@ -6,7 +6,7 @@ export const ServerUpgradeRequired = () => {
const server = useServer();
return (
<div className="min-w-screen flex h-full min-h-screen w-full items-center justify-center">
<div className="flex h-full w-full items-center justify-center">
<div className="flex flex-col items-center gap-8 text-center w-128">
<CircleFadingArrowUp className="h-10 w-10 text-gray-800" />
<h2 className="text-4xl text-gray-800">Server upgrade required</h2>

View File

@@ -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;
}