Rework servers data to allow instances with inaccessible APIs

This commit is contained in:
John Wesley 2024-07-16 12:16:46 -04:00
parent b49ee68138
commit a44e087eae
3 changed files with 145 additions and 91 deletions

View file

@ -6,23 +6,28 @@ export default function Nav() {
path == location.pathname path == location.pathname
? 'border-sky-600' ? 'border-sky-600'
: 'border-transparent hover:border-sky-600'; : 'border-transparent hover:border-sky-600';
const navItemClass = 'border-b-2 mx-1.5 sm:mx-6 ';
return ( return (
<nav class="flex px-4 py-2 items-center bg-[#29144a]"> <nav class="flex px-4 py-2 items-center bg-[#29144a]">
<a href="/"> <a href="/">
<img src="/logo.svg" class="size-8" /> <img src="/logo.svg" class="size-8" />
</a> </a>
<ul class="container flex items-center p-3 text-gray-200"> <ul class="container flex items-center p-3 text-gray-200">
<li class={`border-b-2 ${active('/')} mx-1.5 sm:mx-6`}> <li class={navItemClass + active('/')}>
<a href="/">Home</a> <a href="/">Home</a>
</li> </li>
<li class={`border-b-2 ${active('/servers')} mx-1.5 sm:mx-6`}> <li class={navItemClass + active('/servers')}>
<a href="/servers">Servers</a> <a href="/servers">Servers</a>
</li> </li>
<span class="grow"></span> <span class="grow"></span>
<li class={`border-b-2 mx-1.5 sm:mx-6`}>
<li class={navItemClass}>
<a href="https://docs.joinmbin.org/">Docs</a> <a href="https://docs.joinmbin.org/">Docs</a>
</li> </li>
<li class={`border-b-2 mx-1.5 sm:mx-6`}> <li class={navItemClass}>
<a href="https://github.com/mbinOrg/mbin">GitHub</a> <a href="https://github.com/mbinOrg/mbin">GitHub</a>
</li> </li>
</ul> </ul>

View file

@ -73,62 +73,88 @@ const fetchServerList = async () => {
const fetchServerInfo = async (domain) => { const fetchServerInfo = async (domain) => {
console.log('START:', domain); console.log('START:', domain);
const jsonNodeInfo = await ( let jsonNodeInfo;
await fetch(`https://${domain}/nodeinfo/2.1.json`) try {
).json(); jsonNodeInfo = await (
await fetch(`https://${domain}/nodeinfo/2.1.json`)
).json();
if (jsonNodeInfo.software.name != 'mbin') { if (jsonNodeInfo.software.name != 'mbin') {
throw new Error(`${domain} software does not match mbin (skipped)`); throw new Error(`software check failed`);
}
} catch (error) {
throw new Error(`${domain}: invalid nodeinfo response (skip)`, {
cause: error,
});
} }
const jsonApiInfo = await (await fetch(`https://${domain}/api/info`)).json(); /** @type {import('./routes/servers').Server['api']} */
if (jsonApiInfo.websiteDomain != domain) { let apiOutput;
throw new Error(`${domain} api not setup correctly (skipped)`); try {
const jsonApiInfo = await (
await fetch(`https://${domain}/api/info`)
).json();
if (jsonApiInfo.websiteDomain != domain) {
throw new Error(`domain check failed`);
}
const jsonApiInstance = await (
await fetch(`https://${domain}/api/instance`)
).json();
const jsonApiDefederated = await (
await fetch(`https://${domain}/api/defederated`)
).json();
apiOutput = {
defaultLang: jsonApiInfo.websiteDefaultLang ?? 'en',
federationEnabled: jsonApiInfo.websiteFederationEnabled,
contactEmail: jsonApiInfo.websiteContactEmail,
pages: jsonApiInstance,
defederated: jsonApiDefederated.instances ?? [],
};
} catch (error) {
console.error(
new Error(`${domain}: invalid api response (continue)`, {
cause: error,
}),
);
} }
const jsonApiInstance = await (
await fetch(`https://${domain}/api/instance`)
).json();
const jsonApiDefederated = await (
await fetch(`https://${domain}/api/defederated`)
).json();
console.log('FINISH:', domain); /** @type {import('./routes/servers').Server} */
const output = {
return {
domain: domain, domain: domain,
version: jsonNodeInfo.software.version, version: jsonNodeInfo.software.version,
name: jsonNodeInfo.metadata.nodeName, name: jsonNodeInfo.metadata.nodeName,
description: jsonNodeInfo.metadata.nodeDescription, description: jsonNodeInfo.metadata.nodeDescription,
openRegistrations: jsonNodeInfo.openRegistrations, openRegistrations: jsonNodeInfo.openRegistrations,
federationEnabled: jsonApiInfo.websiteFederationEnabled,
language: jsonApiInfo.websiteDefaultLang ?? 'en',
contactEmail: jsonApiInfo.websiteContactEmail,
totalUsers: jsonNodeInfo.usage.users.total, totalUsers: jsonNodeInfo.usage.users.total,
activeHalfyearUsers: jsonNodeInfo.usage.users.activeHalfyear, activeHalfyearUsers: jsonNodeInfo.usage.users.activeHalfyear,
activeMonthUsers: jsonNodeInfo.usage.users.activeMonth, activeMonthUsers: jsonNodeInfo.usage.users.activeMonth,
localPosts: jsonNodeInfo.usage.localPosts, localPosts: jsonNodeInfo.usage.localPosts,
localComments: jsonNodeInfo.usage.localComments, localComments: jsonNodeInfo.usage.localComments,
pages: jsonApiInstance, api: apiOutput,
defederated: jsonApiDefederated.instances ?? [],
}; };
console.log('FINISH:', domain);
return output;
}; };
const initServerData = async () => { const initServerData = async () => {
const servers = await fetchServerList(); const serverDomains = await fetchServerList();
const serversJson = (await Promise.allSettled(servers.map(fetchServerInfo))) const serversJson = (
await Promise.allSettled(serverDomains.map(fetchServerInfo))
)
.filter((v) => { .filter((v) => {
const isOk = v.status == 'fulfilled'; const isOk = v.status == 'fulfilled';
if (!isOk) { if (!isOk) console.error(v.reason);
console.error(v.reason);
}
return isOk; return isOk;
}) })
.map((v) => v.value); .map((v) => v.value);
console.log('Successful Mbin servers found:', serversJson.length); console.log('Mbin servers found:', serversJson.length);
fs.writeFile( fs.writeFile(
'./.output/data/servers.json', './.output/data/servers.json',

View file

@ -41,25 +41,27 @@ export interface Server {
name: string; name: string;
description: string; description: string;
openRegistrations: boolean; openRegistrations: boolean;
federationEnabled: boolean;
language: string;
contactEmail: string;
totalUsers: number; totalUsers: number;
activeHalfyearUsers: number; activeHalfyearUsers: number;
activeMonthUsers: number; activeMonthUsers: number;
localPosts: number; localPosts: number;
localComments: number; localComments: number;
pages: { api?: {
about?: string; contactEmail: string;
contact?: string; federationEnabled: boolean;
faq?: string; defaultLang: string;
privacyPolicy?: string; pages: {
terms?: string; about?: string;
contact?: string;
faq?: string;
privacyPolicy?: string;
terms?: string;
};
defederated: string[];
}; };
defederated: string[];
} }
const pageNames: Required<Server['pages']> = { const pageNames: Required<NonNullable<Server['api']>['pages']> = {
about: 'About', about: 'About',
contact: 'Contact', contact: 'Contact',
faq: 'Frequently Asked Questions', faq: 'Frequently Asked Questions',
@ -88,7 +90,7 @@ export default function ServersPage() {
.filter( .filter(
(server) => (server) =>
(!filterRegistration() || server.openRegistrations) && (!filterRegistration() || server.openRegistrations) &&
(!langFilter() || server.language == langFilter()), (!langFilter() || server.api?.defaultLang == langFilter()),
) )
.sort((a, b) => { .sort((a, b) => {
switch (sort()) { switch (sort()) {
@ -123,7 +125,7 @@ export default function ServersPage() {
<Select <Select
value={langFilter()} value={langFilter()}
onChange={setLangFilter} onChange={setLangFilter}
options={[...new Set(servers.map((v) => v.language))]} options={[...new Set(servers.map((v) => v.api?.defaultLang))]}
placeholder="All Languages" placeholder="All Languages"
itemComponent={(props) => ( itemComponent={(props) => (
<SelectItem item={props.item}> <SelectItem item={props.item}>
@ -131,7 +133,7 @@ export default function ServersPage() {
</SelectItem> </SelectItem>
)} )}
> >
<SelectTrigger aria-label="Fruit" class="w-[180px]"> <SelectTrigger aria-label="Language" class="w-[180px]">
<SelectValue<string>> <SelectValue<string>>
{(state) => languageNames.of(state.selectedOption())} {(state) => languageNames.of(state.selectedOption())}
</SelectValue> </SelectValue>
@ -151,7 +153,7 @@ export default function ServersPage() {
)} )}
> >
<SelectTrigger aria-label="Fruit" class="w-[180px]"> <SelectTrigger aria-label="Fruit" class="w-[180px]">
<SelectValue<string>> <SelectValue<keyof typeof sortNameMap>>
{(state) => sortNameMap[state.selectedOption()]} {(state) => sortNameMap[state.selectedOption()]}
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
@ -178,9 +180,11 @@ export default function ServersPage() {
const StatChips = () => ( const StatChips = () => (
<> <>
<Chip title="Mbin Version">Mbin {server.version}</Chip> <Chip title="Mbin Version">Mbin {server.version}</Chip>
<Chip title="Language" icon={MaterialSymbolsLanguage}> <Show when={server.api}>
{languageNames.of(server.language)} <Chip title="Language" icon={MaterialSymbolsLanguage}>
</Chip> {languageNames.of(server.api!.defaultLang)}
</Chip>
</Show>
<Chip title="Total users" icon={MaterialSymbolsPerson}> <Chip title="Total users" icon={MaterialSymbolsPerson}>
{server.totalUsers} {server.totalUsers}
</Chip> </Chip>
@ -254,48 +258,67 @@ export default function ServersPage() {
</Chip> </Chip>
</div> </div>
<Accordion <Show
multiple={false} when={server.api}
collapsible fallback={
defaultValue={['about']} <div>
No information available. This server's api is
inaccessible and it's not recommended to use.
</div>
}
> >
<For each={Object.entries(server.pages)}> <Accordion
{([page, pageContent]) => ( multiple={false}
<Show when={pageContent || page === 'contact'}> collapsible
<AccordionItem value={page}> defaultValue={['about']}
<AccordionTrigger> >
{pageNames[page as keyof Server['pages']]} <For each={Object.entries(server.api!.pages)}>
</AccordionTrigger> {([page, pageContent]) => (
<AccordionContent> <Show when={pageContent || page === 'contact'}>
<Show when={page == 'contact'}> <AccordionItem value={page}>
<Button <AccordionTrigger>
href={`mailto:${server.contactEmail}`} {
as="a" pageNames[
> page as keyof NonNullable<
Email admin: {server.contactEmail} Server['api']
</Button> >['pages']
</Show> ]
<Markdown>{pageContent}</Markdown> }
</AccordionContent> </AccordionTrigger>
</AccordionItem> <AccordionContent>
</Show> <Show when={page == 'contact'}>
)} <Button
</For> href={`mailto:${
<Show when={server.defederated.length}> server.api!.contactEmail
<AccordionItem value="defederated"> }`}
<AccordionTrigger> as="a"
Defederated Servers ({server.defederated.length}) >
</AccordionTrigger> Email admin: {server.api!.contactEmail}
<AccordionContent> </Button>
<div class="flex flex-wrap gap-1"> </Show>
<For each={server.defederated}> <Markdown>{pageContent}</Markdown>
{(server) => <Chip>{server}</Chip>} </AccordionContent>
</For> </AccordionItem>
</div> </Show>
</AccordionContent> )}
</AccordionItem> </For>
</Show> <Show when={server.api!.defederated.length}>
</Accordion> <AccordionItem value="defederated">
<AccordionTrigger>
Defederated Servers (
{server.api!.defederated.length})
</AccordionTrigger>
<AccordionContent>
<div class="flex flex-wrap gap-1">
<For each={server.api!.defederated}>
{(server) => <Chip>{server}</Chip>}
</For>
</div>
</AccordionContent>
</AccordionItem>
</Show>
</Accordion>
</Show>
<Show when={server.openRegistrations}> <Show when={server.openRegistrations}>
<DialogFooter> <DialogFooter>