Skip to content

Commit

Permalink
Show loading server status (#1442)
Browse files Browse the repository at this point in the history
* avoid race condition by overwriting the cache rather than removing and adding in during a refresh

* move homeView into a home folder

* refactor home view and pull the server list out into it's own component

* move server into it's own component outside the server list

* display a skeleton when servers are loading

* show loading items status when the remote projects are still loading but the server status has loaded
  • Loading branch information
hahn-kev authored Feb 4, 2025
1 parent 6af311b commit 8f43011
Show file tree
Hide file tree
Showing 5 changed files with 217 additions and 152 deletions.
12 changes: 3 additions & 9 deletions backend/FwLite/LcmCrdt/CurrentProjectService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ public class CurrentProjectService(IServiceProvider services, IMemoryCache memor
//only works because PopulateProjectDataCache is called first in the request pipeline
public ProjectData ProjectData => memoryCache.Get<ProjectData>(CacheKey(Project)) ?? throw new InvalidOperationException("Project data not found, call PopulateProjectDataCache first or use GetProjectData");

public async ValueTask<ProjectData> GetProjectData()
public async ValueTask<ProjectData> GetProjectData(bool forceRefresh = false)
{
var key = CacheKey(Project);
if (!memoryCache.TryGetValue(key, out object? result))
if (!memoryCache.TryGetValue(key, out object? result) || forceRefresh)
{
result = await DbContext.ProjectData.AsNoTracking().FirstAsync();
memoryCache.Set(key, result);
Expand Down Expand Up @@ -81,16 +81,10 @@ public async ValueTask<ProjectData> SetupProjectContext(string projectName)

public async ValueTask<ProjectData> RefreshProjectData()
{
RemoveProjectDataCache();
var projectData = await GetProjectData();
var projectData = await GetProjectData(true);
return projectData;
}

private void RemoveProjectDataCache()
{
memoryCache.Remove(CacheKey(Project));
}

public async Task SetProjectSyncOrigin(Uri? domain, Guid? id)
{
var originDomain = ProjectData.GetOriginDomain(domain);
Expand Down
2 changes: 1 addition & 1 deletion frontend/viewer/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<script lang="ts">
import {Router, Route} from 'svelte-routing';
import TestProjectView from './TestProjectView.svelte';
import HomeView from './HomeView.svelte';
import HomeView from './home/HomeView.svelte';
import NotificationOutlet from './lib/notifications/NotificationOutlet.svelte';
import Sandbox from './lib/sandbox/Sandbox.svelte';
import {settings} from 'svelte-ux';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,37 +1,28 @@
<script lang="ts">
import {
mdiBookArrowDownOutline,
mdiBookArrowLeftOutline,
mdiBookEditOutline,
mdiBookPlusOutline,
mdiBookSyncOutline,
mdiChatQuestion,
mdiChevronRight,
mdiCloud,
mdiFaceAgent,
mdiRefresh,
mdiTestTube,
} from '@mdi/js';
import {AppBar, Button, Icon, ListItem} from 'svelte-ux';
import flexLogo from './lib/assets/flex-logo.png';
import logoLight from './lib/assets/logo-light.svg';
import logoDark from './lib/assets/logo-dark.svg';
import DevContent, {isDev} from './lib/layout/DevContent.svelte';
import {type Project} from './lib/services/projects-service';
import {onMount} from 'svelte';
import {AppBar, Button, ListItem} from 'svelte-ux';
import flexLogo from '$lib/assets/flex-logo.png';
import logoLight from '$lib/assets/logo-light.svg';
import logoDark from '$lib/assets/logo-dark.svg';
import DevContent, {isDev} from '$lib/layout/DevContent.svelte';
import {
useAuthService,
useFwLiteConfig,
useImportFwdataService,
useProjectsService, useTroubleshootingService
} from './lib/services/service-provider';
import type {ILexboxServer, IServerStatus} from '$lib/dotnet-types';
import LoginButton from '$lib/auth/LoginButton.svelte';
} from '$lib/services/service-provider';
import AnchorListItem from '$lib/utils/AnchorListItem.svelte';
import TroubleshootDialog from '$lib/troubleshoot/TroubleshootDialog.svelte';
import ServersList from './ServersList.svelte';
const projectsService = useProjectsService();
const authService = useAuthService();
const importFwdataService = useImportFwdataService();
const fwLiteConfig = useFwLiteConfig();
const exampleProjectName = 'Example-Project';
Expand Down Expand Up @@ -67,18 +58,6 @@
}
}
let downloading = '';
async function downloadCrdtProject(project: Project, server: ILexboxServer) {
downloading = project.name;
if (project.id == null) throw new Error('Project id is null');
try {
await projectsService.downloadProject(project.id, project.name, server);
await refreshProjects();
} finally {
downloading = '';
}
}
let projectsPromise = projectsService.localProjects().then(projects => projects.sort((p1, p2) => p1.name.localeCompare(p2.name)));
Expand All @@ -88,52 +67,7 @@
projectsPromise = promise;
}
let remoteProjects: { [server: string]: Project[] } = {};
let loadingRemoteProjects = false;
async function fetchRemoteProjects(force: boolean = false): Promise<void> {
loadingRemoteProjects = true;
try {
let result = await projectsService.remoteProjects(force);
for (let serverProjects of result) {
remoteProjects[serverProjects.server.id] = serverProjects.projects;
}
remoteProjects = remoteProjects;
} finally {
loadingRemoteProjects = false;
}
}
let loadingServerProjects: undefined | string = undefined;
async function refreshServerProjects(server: ILexboxServer, force: boolean = false) {
loadingServerProjects = server.id;
remoteProjects[server.id] = await projectsService.serverProjects(server.id, force);
remoteProjects = remoteProjects;
loadingServerProjects = undefined;
}
fetchRemoteProjects().catch((error) => {
console.error(`Failed to fetch remote projects`, error);
throw error;
});
async function refreshProjectsAndServers() {
await fetchRemoteProjects();
serversStatus = await authService.servers();
}
let serversStatus: IServerStatus[] = [];
onMount(async () => {
serversStatus = await authService.servers();
});
function matchesProject(projects: Project[], project: Project): Project | undefined {
if (project.id) {
return projects.find(p => p.id == project.id && p.server?.id == project.server?.id);
}
return undefined;
}
const supportsTroubleshooting = useTroubleshootingService();
let showTroubleshooting = false;
</script>
Expand Down Expand Up @@ -212,71 +146,7 @@
{/if}
</div>
</div>
{#each serversStatus as status}
{@const server = status.server}
{@const serverProjects = remoteProjects[server.id]?.filter(p => p.crdt) ?? []}
<div>
<div class="flex flex-row mb-2 items-end mr-2 md:mr-0">
<p class="sub-title !my-0">
{server.displayName} Server
</p>
<div class="flex-grow"></div>
{#if status.loggedIn}
<Button icon={mdiRefresh}
title="Refresh Projects"
disabled={loadingServerProjects === server.id}
on:click={() => refreshServerProjects(server, true)}/>
<LoginButton {status} on:status={() => refreshProjectsAndServers()}/>
{/if}
</div>
<div>
{#if !serverProjects.length}
<p class="text-surface-content/50 text-center elevation-1 md:rounded p-4">
{#if status.loggedIn}
No projects
{:else}
<LoginButton {status} on:status={() => refreshProjectsAndServers()}/>
{/if}
</p>
{/if}
{#if loadingServerProjects === server.id}
<p class="text-surface-content/50 text-center elevation-1 md:rounded p-4">
<Icon data={mdiRefresh} class="animate-spin"/>
Loading...
</p>
{:else}
{#each serverProjects as project}
{@const localProject = matchesProject(projects, project)}
{#if localProject?.crdt}
<AnchorListItem href={`/project/${project.name}`}>
<ListItem icon={mdiCloud}
title={project.name}
loading={downloading === project.name}>
<div slot="actions" class="pointer-events-none">
<Button disabled icon={mdiBookSyncOutline} class="p-2">
Synced
</Button>
</div>
</ListItem>
</AnchorListItem>
{:else}
<ListItem icon={mdiCloud}
title={project.name}
on:click={() => void downloadCrdtProject(project, server)}
loading={downloading === project.name}>
<div slot="actions" class="pointer-events-none">
<Button icon={mdiBookArrowDownOutline} class="p-2">
Download
</Button>
</div>
</ListItem>
{/if}
{/each}
{/if}
</div>
</div>
{/each}

<ServersList localProjects={projects} {refreshProjects}/>
{#if projects.some(p => p.fwdata)}
<div>
<p class="sub-title">Classic FieldWorks Projects</p>
Expand Down Expand Up @@ -324,9 +194,10 @@
@apply max-md:!rounded-none;
@apply contrast-[0.95];
}
}
.sub-title {
@apply m-2;
@apply text-surface-content/50 text-sm;
:global(.sub-title) {
@apply m-2;
@apply text-surface-content/50 text-sm;
}
}
</style>
111 changes: 111 additions & 0 deletions frontend/viewer/src/home/Server.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<script lang="ts">
import type {ILexboxServer, IServerStatus} from '$lib/dotnet-types';
import type {Project} from '$lib/services/projects-service';
import {createEventDispatcher} from 'svelte';
import {mdiBookArrowDownOutline, mdiBookSyncOutline, mdiCloud, mdiRefresh} from '@mdi/js';
import LoginButton from '$lib/auth/LoginButton.svelte';
import {Button, Icon, ListItem, Settings} from 'svelte-ux';
import AnchorListItem from '$lib/utils/AnchorListItem.svelte';
import {useProjectsService} from '$lib/services/service-provider';
const projectsService = useProjectsService();
const dispatch = createEventDispatcher<{
refreshProjects: void,
refreshAll: void,
}>();
export let status: IServerStatus | undefined;
$: server = status?.server;
export let projects: Project[];
export let localProjects: Project[];
export let loading: boolean;
let downloading = '';
async function downloadCrdtProject(project: Project, server: ILexboxServer | undefined) {
if (!server) throw new Error('Server is undefined');
downloading = project.name;
if (project.id == null) throw new Error('Project id is null');
try {
await projectsService.downloadProject(project.id, project.name, server);
dispatch('refreshAll');
} finally {
downloading = '';
}
}
function matchesProject(projects: Project[], project: Project): Project | undefined {
if (project.id) {
return projects.find(p => p.id == project.id && p.server?.id == project.server?.id);
}
return undefined;
}
</script>
<div>
<div class="flex flex-row mb-2 items-end mr-2 md:mr-0">
<p class="sub-title !my-0">
{#if server}
{server.displayName} Server
{:else}
<div class="h-2 w-28 bg-surface-content/50 rounded-full animate-pulse"></div>
{/if}
</p>
<div class="flex-grow"></div>
{#if status?.loggedIn}
<Button icon={mdiRefresh}
title="Refresh Projects"
disabled={loading}
on:click={() => dispatch('refreshProjects')}/>
<LoginButton {status} on:status={() => dispatch('refreshAll')}/>
{/if}
</div>
<div>
{#if !status || loading}
<!--override the defaults from App.svelte-->
<Settings components={{ListItem: {classes: {root: 'animate-pulse'}}}}>
<ListItem icon={mdiCloud} classes={{icon: 'text-neutral-50/50'}}>
<div slot="title" class="h-4 bg-neutral-50/50 rounded-full w-32">
</div>
<div slot="actions" class="pointer-events-none">
<div class="h-4 my-3 bg-neutral-50/50 rounded-full w-20"></div>
</div>
</ListItem>
</Settings>
{:else if !projects.length}
<p class="text-surface-content/50 text-center elevation-1 md:rounded p-4">
{#if status.loggedIn}
No projects
{:else}
<LoginButton {status} on:status={() => dispatch('refreshAll')}/>
{/if}
</p>
{:else}
{#each projects as project}
{@const localProject = matchesProject(localProjects, project)}
{#if localProject?.crdt}
<AnchorListItem href={`/project/${project.name}`}>
<ListItem icon={mdiCloud}
title={project.name}
loading={downloading === project.name}>
<div slot="actions" class="pointer-events-none">
<Button disabled icon={mdiBookSyncOutline} class="p-2">
Synced
</Button>
</div>
</ListItem>
</AnchorListItem>
{:else}
<ListItem icon={mdiCloud}
title={project.name}
on:click={() => void downloadCrdtProject(project, server)}
loading={downloading === project.name}>
<div slot="actions" class="pointer-events-none">
<Button icon={mdiBookArrowDownOutline} class="p-2">
Download
</Button>
</div>
</ListItem>
{/if}
{/each}
{/if}
</div>
</div>
Loading

0 comments on commit 8f43011

Please sign in to comment.