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,17 +73,29 @@ const fetchServerList = async () => {
const fetchServerInfo = async (domain) => { const fetchServerInfo = async (domain) => {
console.log('START:', domain); console.log('START:', domain);
const jsonNodeInfo = await ( let jsonNodeInfo;
try {
jsonNodeInfo = await (
await fetch(`https://${domain}/nodeinfo/2.1.json`) await fetch(`https://${domain}/nodeinfo/2.1.json`)
).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']} */
let apiOutput;
try {
const jsonApiInfo = await (
await fetch(`https://${domain}/api/info`)
).json();
if (jsonApiInfo.websiteDomain != domain) { if (jsonApiInfo.websiteDomain != domain) {
throw new Error(`${domain} api not setup correctly (skipped)`); throw new Error(`domain check failed`);
} }
const jsonApiInstance = await ( const jsonApiInstance = await (
await fetch(`https://${domain}/api/instance`) await fetch(`https://${domain}/api/instance`)
@ -92,43 +104,57 @@ const fetchServerInfo = async (domain) => {
await fetch(`https://${domain}/api/defederated`) await fetch(`https://${domain}/api/defederated`)
).json(); ).json();
console.log('FINISH:', domain); 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,
}),
);
}
return { /** @type {import('./routes/servers').Server} */
const output = {
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,14 +41,15 @@ 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;
api?: {
contactEmail: string;
federationEnabled: boolean;
defaultLang: string;
pages: { pages: {
about?: string; about?: string;
contact?: string; contact?: string;
@ -57,9 +58,10 @@ export interface Server {
terms?: 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>
<Show when={server.api}>
<Chip title="Language" icon={MaterialSymbolsLanguage}> <Chip title="Language" icon={MaterialSymbolsLanguage}>
{languageNames.of(server.language)} {languageNames.of(server.api!.defaultLang)}
</Chip> </Chip>
</Show>
<Chip title="Total users" icon={MaterialSymbolsPerson}> <Chip title="Total users" icon={MaterialSymbolsPerson}>
{server.totalUsers} {server.totalUsers}
</Chip> </Chip>
@ -254,25 +258,42 @@ export default function ServersPage() {
</Chip> </Chip>
</div> </div>
<Show
when={server.api}
fallback={
<div>
No information available. This server's api is
inaccessible and it's not recommended to use.
</div>
}
>
<Accordion <Accordion
multiple={false} multiple={false}
collapsible collapsible
defaultValue={['about']} defaultValue={['about']}
> >
<For each={Object.entries(server.pages)}> <For each={Object.entries(server.api!.pages)}>
{([page, pageContent]) => ( {([page, pageContent]) => (
<Show when={pageContent || page === 'contact'}> <Show when={pageContent || page === 'contact'}>
<AccordionItem value={page}> <AccordionItem value={page}>
<AccordionTrigger> <AccordionTrigger>
{pageNames[page as keyof Server['pages']]} {
pageNames[
page as keyof NonNullable<
Server['api']
>['pages']
]
}
</AccordionTrigger> </AccordionTrigger>
<AccordionContent> <AccordionContent>
<Show when={page == 'contact'}> <Show when={page == 'contact'}>
<Button <Button
href={`mailto:${server.contactEmail}`} href={`mailto:${
server.api!.contactEmail
}`}
as="a" as="a"
> >
Email admin: {server.contactEmail} Email admin: {server.api!.contactEmail}
</Button> </Button>
</Show> </Show>
<Markdown>{pageContent}</Markdown> <Markdown>{pageContent}</Markdown>
@ -281,14 +302,15 @@ export default function ServersPage() {
</Show> </Show>
)} )}
</For> </For>
<Show when={server.defederated.length}> <Show when={server.api!.defederated.length}>
<AccordionItem value="defederated"> <AccordionItem value="defederated">
<AccordionTrigger> <AccordionTrigger>
Defederated Servers ({server.defederated.length}) Defederated Servers (
{server.api!.defederated.length})
</AccordionTrigger> </AccordionTrigger>
<AccordionContent> <AccordionContent>
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
<For each={server.defederated}> <For each={server.api!.defederated}>
{(server) => <Chip>{server}</Chip>} {(server) => <Chip>{server}</Chip>}
</For> </For>
</div> </div>
@ -296,6 +318,7 @@ export default function ServersPage() {
</AccordionItem> </AccordionItem>
</Show> </Show>
</Accordion> </Accordion>
</Show>
<Show when={server.openRegistrations}> <Show when={server.openRegistrations}>
<DialogFooter> <DialogFooter>