Added more docs
This commit is contained in:
		
							parent
							
								
									f36a1d8912
								
							
						
					
					
						commit
						90a2346cc9
					
				| 
						 | 
				
			
			@ -1,8 +1,12 @@
 | 
			
		|||
# Gymboard App (gymboard-app)
 | 
			
		||||
# Gymboard App
 | 
			
		||||
 | 
			
		||||
Web app for Gymboard
 | 
			
		||||
The responsive web application for Gymboard. This is a Vue 3 + Quasar project that's written in Typescript.
 | 
			
		||||
 | 
			
		||||
## Install the dependencies
 | 
			
		||||
## Developing
 | 
			
		||||
 | 
			
		||||
In order to develop this app, you'll most likely want to start a local instance of each Gymboard service that it relies on. Each service should expose a `./start-dev.sh` script that you can call to quickly boot it up, but refer to each service's README for more detailed information, like generating sample data.
 | 
			
		||||
 | 
			
		||||
### Install the dependencies
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
yarn
 | 
			
		||||
| 
						 | 
				
			
			@ -41,3 +45,12 @@ quasar build
 | 
			
		|||
### Customize the configuration
 | 
			
		||||
 | 
			
		||||
See [Configuring quasar.config.js](https://v2.quasar.dev/quasar-cli-vite/quasar-config-js).
 | 
			
		||||
 | 
			
		||||
## Structure
 | 
			
		||||
 | 
			
		||||
Within `src/`, there are a few key directories whose functions are listed below:
 | 
			
		||||
 | 
			
		||||
- `src/api/` contains a Typescript client implementation for consuming the various APIs from Gymboard services such as the CDN, Search, and main API. The main API (`src/api/main/index.ts`) can be imported with `import api from 'src/api/main';`. While the other APIs may differ based on their usage, generally the main API is structured as a hierarchy of classes with `readonly` properties to act as namespaces.
 | 
			
		||||
- `src/components/` contains auxiliary Vue components that help in building pages.
 | 
			
		||||
- `src/pages/` contains all the pages as Vue components.
 | 
			
		||||
- `src/i18n` contains translation files as JSON objects for all supported languages.
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,73 @@
 | 
			
		|||
import {defaultPaginationOptions, Page, PaginationOptions} from 'src/api/main/models';
 | 
			
		||||
import {isScrolledToBottom} from 'src/utils';
 | 
			
		||||
import {Ref} from 'vue';
 | 
			
		||||
 | 
			
		||||
export type PageFetcherFunction<ElementType> = (paginationOptions: PaginationOptions) => Promise<Page<ElementType> | undefined>;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * A class that manages an "infinite loading" list of elements from a paginated
 | 
			
		||||
 * API endpoint. The loader will use a supplied function to load more pages
 | 
			
		||||
 * when the user reaches the bottom of the page or if pagination options are
 | 
			
		||||
 * updated.
 | 
			
		||||
 *
 | 
			
		||||
 * This loader should be given a reference to an array of elements that can be
 | 
			
		||||
 * updated, to update a reactive Vue display.
 | 
			
		||||
 */
 | 
			
		||||
export default class InfinitePageLoader<ElementType> {
 | 
			
		||||
  private paginationOptions: PaginationOptions = defaultPaginationOptions();
 | 
			
		||||
  private latestPage: Page<ElementType> | null = null;
 | 
			
		||||
  private readonly elements: Ref<ElementType[]>;
 | 
			
		||||
  private readonly pageFetcher: PageFetcherFunction<ElementType>;
 | 
			
		||||
  private fetching = false; // Internal flag used to make sure only one fetch operation happens at a time.
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Constructs the loader with a reference to a list of elements to manage,
 | 
			
		||||
   * and a fetcher function for fetching pages.
 | 
			
		||||
   * @param elements A reference to a reactive list of elements to manage.
 | 
			
		||||
   * @param pageFetcher A function for fetching pages of elements.
 | 
			
		||||
   */
 | 
			
		||||
  public constructor(elements: Ref<ElementType[]>, pageFetcher: PageFetcherFunction<ElementType>) {
 | 
			
		||||
    this.elements = elements;
 | 
			
		||||
    this.pageFetcher = pageFetcher;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Sets the pagination options for this loader. Doing so will reset the
 | 
			
		||||
   * loader's state and means that it'll reload elements from the start.
 | 
			
		||||
   * @param opts The pagination options to apply.
 | 
			
		||||
   */
 | 
			
		||||
  public async setPagination(opts: PaginationOptions) {
 | 
			
		||||
    this.paginationOptions = opts;
 | 
			
		||||
    this.elements.value.length = 0;
 | 
			
		||||
    while (this.shouldFetchNextPage()) {
 | 
			
		||||
      await this.loadNextPage();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Registers a window scroll listener that will try to fetch more elements
 | 
			
		||||
   * when the user has scrolled to the bottom of the page.
 | 
			
		||||
   */
 | 
			
		||||
  public registerWindowScrollListener() {
 | 
			
		||||
    window.addEventListener('scroll', async () => {
 | 
			
		||||
      if (this.shouldFetchNextPage()) {
 | 
			
		||||
        await this.loadNextPage();
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private shouldFetchNextPage(): boolean {
 | 
			
		||||
    return !this.fetching && isScrolledToBottom(10) && (!this.latestPage || !this.latestPage.last);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async loadNextPage() {
 | 
			
		||||
    this.fetching = true;
 | 
			
		||||
    const page = await this.pageFetcher(this.paginationOptions);
 | 
			
		||||
    if (page) {
 | 
			
		||||
      this.latestPage = page;
 | 
			
		||||
      this.elements.value.push(...this.latestPage.content);
 | 
			
		||||
      this.paginationOptions.page++;
 | 
			
		||||
    }
 | 
			
		||||
    this.fetching = false;
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -2,6 +2,7 @@ import { api } from 'src/api/main/index';
 | 
			
		|||
import { AuthStoreType } from 'stores/auth-store';
 | 
			
		||||
import Timeout = NodeJS.Timeout;
 | 
			
		||||
import { WeightUnit } from 'src/api/main/submission';
 | 
			
		||||
import {Page, PaginationOptions, toQueryParams} from "src/api/main/models";
 | 
			
		||||
 | 
			
		||||
export interface User {
 | 
			
		||||
  id: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -228,27 +229,23 @@ class AuthModule {
 | 
			
		|||
  public async getFollowers(
 | 
			
		||||
    userId: string,
 | 
			
		||||
    authStore: AuthStoreType,
 | 
			
		||||
    page: number,
 | 
			
		||||
    count: number
 | 
			
		||||
  ): Promise<User[]> {
 | 
			
		||||
    const response = await api.get(
 | 
			
		||||
      `/auth/users/${userId}/followers?page=${page}&count=${count}`,
 | 
			
		||||
      authStore.axiosConfig
 | 
			
		||||
    );
 | 
			
		||||
    return response.data.content;
 | 
			
		||||
    paginationOptions: PaginationOptions
 | 
			
		||||
  ): Promise<Page<User>> {
 | 
			
		||||
    const config = structuredClone(authStore.axiosConfig);
 | 
			
		||||
    config.params = toQueryParams(paginationOptions);
 | 
			
		||||
    const response = await api.get(`/auth/users/${userId}/followers`, config);
 | 
			
		||||
    return response.data;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async getFollowing(
 | 
			
		||||
    userId: string,
 | 
			
		||||
    authStore: AuthStoreType,
 | 
			
		||||
    page: number,
 | 
			
		||||
    count: number
 | 
			
		||||
  ): Promise<User[]> {
 | 
			
		||||
    const response = await api.get(
 | 
			
		||||
      `/auth/users/${userId}/following?page=${page}&count=${count}`,
 | 
			
		||||
      authStore.axiosConfig
 | 
			
		||||
    );
 | 
			
		||||
    return response.data.content;
 | 
			
		||||
    paginationOptions: PaginationOptions
 | 
			
		||||
  ): Promise<Page<User>> {
 | 
			
		||||
    const config = structuredClone(authStore.axiosConfig);
 | 
			
		||||
    config.params = toQueryParams(paginationOptions);
 | 
			
		||||
    const response = await api.get(`/auth/users/${userId}/following`, config);
 | 
			
		||||
    return response.data;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async getRelationshipTo(
 | 
			
		||||
| 
						 | 
				
			
			@ -283,12 +280,12 @@ class AuthModule {
 | 
			
		|||
    userId: string,
 | 
			
		||||
    reason: string,
 | 
			
		||||
    description: string | null,
 | 
			
		||||
    authStore?: AuthStoreType
 | 
			
		||||
    authStore: AuthStoreType
 | 
			
		||||
  ) {
 | 
			
		||||
    await api.post(
 | 
			
		||||
      `/auth/users/${userId}/reports`,
 | 
			
		||||
      { reason, description },
 | 
			
		||||
      authStore?.axiosConfig
 | 
			
		||||
      authStore.axiosConfig
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -52,3 +52,19 @@ export enum PaginationSortDir {
 | 
			
		|||
  ASC = 'asc',
 | 
			
		||||
  DESC = 'desc'
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class PaginationHelpers {
 | 
			
		||||
  static sortedBy(prop: string, dir: PaginationSortDir): PaginationOptions {
 | 
			
		||||
    const opts = defaultPaginationOptions();
 | 
			
		||||
    opts.sort = { propertyName: prop, sortDir: dir };
 | 
			
		||||
    return opts;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static sortedAscBy(prop: string): PaginationOptions {
 | 
			
		||||
    return PaginationHelpers.sortedBy(prop, PaginationSortDir.ASC);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static sortedDescBy(prop: string): PaginationOptions {
 | 
			
		||||
    return PaginationHelpers.sortedBy(prop, PaginationSortDir.DESC);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -12,7 +12,7 @@ class UsersModule {
 | 
			
		|||
  public async getSubmissions(userId: string, authStore: AuthStoreType, paginationOptions: PaginationOptions = defaultPaginationOptions()): Promise<Page<ExerciseSubmission>> {
 | 
			
		||||
    const config = structuredClone(authStore.axiosConfig);
 | 
			
		||||
    config.params = toQueryParams(paginationOptions);
 | 
			
		||||
    const response = await api.get(`/users/${userId}/submissions`, {...toQueryParams(paginationOptions), ...authStore.axiosConfig});
 | 
			
		||||
    const response = await api.get(`/users/${userId}/submissions`, config);
 | 
			
		||||
    response.data.content = response.data.content.map(parseSubmission);
 | 
			
		||||
    return response.data;
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -16,7 +16,7 @@
 | 
			
		|||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { computed, onMounted, ref, Ref } from 'vue';
 | 
			
		||||
import { onMounted, ref, Ref } from 'vue';
 | 
			
		||||
import { useRoute, useRouter } from 'vue-router';
 | 
			
		||||
import StandardCenteredPage from 'components/StandardCenteredPage.vue';
 | 
			
		||||
import { getGymFromRoute, getGymRoute } from 'src/router/gym-routing';
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -16,6 +16,8 @@ import {User} from 'src/api/main/auth';
 | 
			
		|||
import {useAuthStore} from 'stores/auth-store';
 | 
			
		||||
import {onMounted, ref, Ref} from 'vue';
 | 
			
		||||
import api from 'src/api/main';
 | 
			
		||||
import InfinitePageLoader from 'src/api/infinite-page-loader';
 | 
			
		||||
import {defaultPaginationOptions} from 'src/api/main/models';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  userId: string
 | 
			
		||||
| 
						 | 
				
			
			@ -23,9 +25,17 @@ interface Props {
 | 
			
		|||
const props = defineProps<Props>();
 | 
			
		||||
const authStore = useAuthStore();
 | 
			
		||||
const followers: Ref<User[]> = ref([]);
 | 
			
		||||
const loader = new InfinitePageLoader(followers, async paginationOptions => {
 | 
			
		||||
  try {
 | 
			
		||||
    return await api.auth.getFollowers(props.userId, authStore, paginationOptions);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.log(error);
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
onMounted(async () => {
 | 
			
		||||
  followers.value = await api.auth.getFollowers(props.userId, authStore, 0, 10);
 | 
			
		||||
  loader.registerWindowScrollListener();
 | 
			
		||||
  await loader.setPagination(defaultPaginationOptions());
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -13,6 +13,8 @@ import {User} from 'src/api/main/auth';
 | 
			
		|||
import {useAuthStore} from 'stores/auth-store';
 | 
			
		||||
import {onMounted, ref, Ref} from 'vue';
 | 
			
		||||
import api from 'src/api/main';
 | 
			
		||||
import InfinitePageLoader from 'src/api/infinite-page-loader';
 | 
			
		||||
import {defaultPaginationOptions} from 'src/api/main/models';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  userId: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -20,14 +22,16 @@ interface Props {
 | 
			
		|||
const props = defineProps<Props>();
 | 
			
		||||
const authStore = useAuthStore();
 | 
			
		||||
const following: Ref<User[]> = ref([]);
 | 
			
		||||
 | 
			
		||||
const loader = new InfinitePageLoader(following, async paginationOptions => {
 | 
			
		||||
  try {
 | 
			
		||||
    return await api.auth.getFollowing(props.userId, authStore, paginationOptions);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.log(error);
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
onMounted(async () => {
 | 
			
		||||
  following.value = await api.auth.getFollowing(
 | 
			
		||||
    props.userId,
 | 
			
		||||
    authStore,
 | 
			
		||||
    0,
 | 
			
		||||
    10
 | 
			
		||||
  );
 | 
			
		||||
  loader.registerWindowScrollListener();
 | 
			
		||||
  await loader.setPagination(defaultPaginationOptions());
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,3 +1,12 @@
 | 
			
		|||
<!--
 | 
			
		||||
The page for displaying a user's profile. This is the main landing point when
 | 
			
		||||
someone wants to look at a particular user. It has a basic header area with
 | 
			
		||||
some simple information about the user, and then a page menu that provides
 | 
			
		||||
navigation to the different sub-pages on the user page:
 | 
			
		||||
- Lifts (default)
 | 
			
		||||
- Followers (list of users that follow this one)
 | 
			
		||||
- Following (list of users that this one follows)
 | 
			
		||||
-->
 | 
			
		||||
<template>
 | 
			
		||||
  <q-page>
 | 
			
		||||
    <StandardCenteredPage v-if="profile">
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,19 +1,14 @@
 | 
			
		|||
<template>
 | 
			
		||||
  <div>
 | 
			
		||||
    <div v-if="loadedSubmissions.length > 0">
 | 
			
		||||
    <div v-if="submissions.length > 0">
 | 
			
		||||
      <q-list separator>
 | 
			
		||||
        <ExerciseSubmissionListItem
 | 
			
		||||
          v-for="sub in loadedSubmissions"
 | 
			
		||||
          v-for="sub in submissions"
 | 
			
		||||
          :submission="sub"
 | 
			
		||||
          :key="sub.id"
 | 
			
		||||
          :show-name="false"
 | 
			
		||||
        />
 | 
			
		||||
      </q-list>
 | 
			
		||||
      <div class="text-center">
 | 
			
		||||
        <q-btn id="loadMoreButton" v-if="lastSubmissionsPage && !lastSubmissionsPage.last" @click="loadNextPage(true)">
 | 
			
		||||
          Load more
 | 
			
		||||
        </q-btn>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
| 
						 | 
				
			
			@ -22,12 +17,13 @@
 | 
			
		|||
import {useI18n} from 'vue-i18n';
 | 
			
		||||
import {useQuasar} from 'quasar';
 | 
			
		||||
import {useAuthStore} from 'stores/auth-store';
 | 
			
		||||
import {nextTick, onMounted, ref, Ref} from 'vue';
 | 
			
		||||
import {ExerciseSubmission} from 'src/api/main/submission';
 | 
			
		||||
import {onMounted, ref, Ref} from 'vue';
 | 
			
		||||
import api from 'src/api/main';
 | 
			
		||||
import ExerciseSubmissionListItem from 'components/ExerciseSubmissionListItem.vue';
 | 
			
		||||
import {showApiErrorToast} from 'src/utils';
 | 
			
		||||
import {Page, PaginationOptions, PaginationSortDir} from 'src/api/main/models';
 | 
			
		||||
import {PaginationHelpers} from 'src/api/main/models';
 | 
			
		||||
import InfinitePageLoader from 'src/api/infinite-page-loader';
 | 
			
		||||
import {ExerciseSubmission} from 'src/api/main/submission';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  userId: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -38,24 +34,10 @@ const i18n = useI18n();
 | 
			
		|||
const quasar = useQuasar();
 | 
			
		||||
const authStore = useAuthStore();
 | 
			
		||||
 | 
			
		||||
const lastSubmissionsPage: Ref<Page<ExerciseSubmission> | undefined> = ref();
 | 
			
		||||
const loadedSubmissions: Ref<ExerciseSubmission[]> = ref([]);
 | 
			
		||||
const paginationOptions: PaginationOptions = {page: 0, size: 10};
 | 
			
		||||
onMounted(async () => {
 | 
			
		||||
  resetPagination();
 | 
			
		||||
  await loadNextPage(false);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
async function loadNextPage(scroll: boolean) {
 | 
			
		||||
const submissions: Ref<ExerciseSubmission[]> = ref([]);
 | 
			
		||||
const loader = new InfinitePageLoader(submissions, async paginationOptions => {
 | 
			
		||||
  try {
 | 
			
		||||
    lastSubmissionsPage.value = await api.users.getSubmissions(props.userId, authStore, paginationOptions);
 | 
			
		||||
    loadedSubmissions.value.push(...lastSubmissionsPage.value.content);
 | 
			
		||||
    paginationOptions.page++;
 | 
			
		||||
    await nextTick();
 | 
			
		||||
    const button = document.getElementById('loadMoreButton');
 | 
			
		||||
    if (scroll && button) {
 | 
			
		||||
      button.scrollIntoView({ behavior: 'smooth' });
 | 
			
		||||
    }
 | 
			
		||||
    return await api.users.getSubmissions(props.userId, authStore, paginationOptions);
 | 
			
		||||
  } catch (error: any) {
 | 
			
		||||
    if (error.response) {
 | 
			
		||||
      showApiErrorToast(i18n, quasar);
 | 
			
		||||
| 
						 | 
				
			
			@ -63,13 +45,12 @@ async function loadNextPage(scroll: boolean) {
 | 
			
		|||
      console.log(error);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
function resetPagination() {
 | 
			
		||||
  paginationOptions.page = 0;
 | 
			
		||||
  paginationOptions.size = 10;
 | 
			
		||||
  paginationOptions.sort = { propertyName: 'performedAt', sortDir: PaginationSortDir.DESC };
 | 
			
		||||
}
 | 
			
		||||
onMounted(async () => {
 | 
			
		||||
  loader.registerWindowScrollListener();
 | 
			
		||||
  await loader.setPagination(PaginationHelpers.sortedDescBy('performedAt'));
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style scoped>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -17,3 +17,16 @@ export function showInfoToast(quasar: QVueGlobals, translatedMessage: string) {
 | 
			
		|||
    position: 'top'
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function getDocumentHeight() {
 | 
			
		||||
  const d = document;
 | 
			
		||||
  return Math.max(
 | 
			
		||||
    d.body.scrollHeight, d.documentElement.scrollHeight,
 | 
			
		||||
    d.body.offsetHeight, d.documentElement.offsetHeight,
 | 
			
		||||
    d.body.clientHeight, d.documentElement.clientHeight
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function isScrolledToBottom(margin = 0) {
 | 
			
		||||
  return window.scrollY + window.innerHeight + margin >= getDocumentHeight();
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,6 +3,9 @@
 | 
			
		|||
		"Andrew Lalis"
 | 
			
		||||
	],
 | 
			
		||||
	"copyright": "Copyright © 2023, Andrew Lalis",
 | 
			
		||||
	"dependencies": {
 | 
			
		||||
		"console-colors": "~>1.1.0"
 | 
			
		||||
	},
 | 
			
		||||
	"description": "CLI for developing Gymboard",
 | 
			
		||||
	"license": "proprietary",
 | 
			
		||||
	"name": "gymboard-cli"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,6 @@
 | 
			
		|||
{
 | 
			
		||||
	"fileVersion": 1,
 | 
			
		||||
	"versions": {
 | 
			
		||||
		"console-colors": "1.1.0"
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -3,14 +3,17 @@ import std.stdio;
 | 
			
		|||
import cli;
 | 
			
		||||
import services;
 | 
			
		||||
 | 
			
		||||
import consolecolors;
 | 
			
		||||
 | 
			
		||||
void main() {
 | 
			
		||||
	ServiceManager serviceManager = new ServiceManager();
 | 
			
		||||
	CliHandler cliHandler = new CliHandler();
 | 
			
		||||
	cliHandler.register("service", new ServiceCommand(serviceManager));
 | 
			
		||||
	writeln("Gymboard CLI: Type \"help\" for more information. Type \"exit\" to exit the CLI.");
 | 
			
		||||
	cwriteln("Gymboard CLI: Type <cyan>help</cyan> for more information. Type <red>exit</red> to exit the CLI.");
 | 
			
		||||
	while (!cliHandler.shouldExit) {
 | 
			
		||||
		cwrite("> ".blue);
 | 
			
		||||
		cliHandler.readAndHandleCommand();
 | 
			
		||||
	}
 | 
			
		||||
	serviceManager.stopAll();
 | 
			
		||||
	writeln("Goodbye!");
 | 
			
		||||
	cwriteln("Goodbye!".green);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,6 +3,9 @@ module cli;
 | 
			
		|||
import std.stdio;
 | 
			
		||||
import std.string;
 | 
			
		||||
import std.uni;
 | 
			
		||||
import std.typecons;
 | 
			
		||||
 | 
			
		||||
import consolecolors;
 | 
			
		||||
 | 
			
		||||
interface CliCommand {
 | 
			
		||||
    void handle(string[] args);
 | 
			
		||||
| 
						 | 
				
			
			@ -27,7 +30,7 @@ class CliHandler {
 | 
			
		|||
        } else if (command in commands) {
 | 
			
		||||
            commands[command].handle(commandAndArgs.length > 1 ? commandAndArgs[1 .. $] : []);
 | 
			
		||||
        } else {
 | 
			
		||||
            writefln!"Unknown command \"%s\"."(command);
 | 
			
		||||
            cwritefln("Unknown command: %s".red, command.orange);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -54,34 +57,53 @@ class ServiceCommand : CliCommand {
 | 
			
		|||
 | 
			
		||||
    void handle(string[] args) {
 | 
			
		||||
        if (args.length == 0) {
 | 
			
		||||
            writeln("Missing subcommand.");
 | 
			
		||||
            cwriteln("Missing subcommand.".red);
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        string subcommand = args[0];
 | 
			
		||||
        if (subcommand == "status") {
 | 
			
		||||
            auto statuses = serviceManager.getStatus();
 | 
			
		||||
            if (statuses.length == 0) {
 | 
			
		||||
                writeln("No services running.");
 | 
			
		||||
                cwriteln("No services running.".orange);
 | 
			
		||||
            }
 | 
			
		||||
            foreach (status; statuses) {
 | 
			
		||||
                writefln!"%s: Running = %s, Exit code = %s"(status.name, status.running, status.exitCode);
 | 
			
		||||
            }
 | 
			
		||||
        } else if (subcommand == "start") {
 | 
			
		||||
            if (args.length < 2) {
 | 
			
		||||
                writeln("Missing service name.");
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
            auto result = serviceManager.startService(args[1]);
 | 
			
		||||
            writeln(result.msg);
 | 
			
		||||
            auto info = validateServiceNameArg(args);
 | 
			
		||||
            if (!info.isNull) serviceManager.startService(info.get);
 | 
			
		||||
        } else if (subcommand == "stop") {
 | 
			
		||||
            if (args.length < 2) {
 | 
			
		||||
                writeln("Missing service name.");
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
            auto result = serviceManager.stopService(args[1]);
 | 
			
		||||
            writeln(result.msg);
 | 
			
		||||
            auto info = validateServiceNameArg(args);
 | 
			
		||||
            if (!info.isNull) serviceManager.stopService(info.get);
 | 
			
		||||
        } else if (subcommand == "logs") {
 | 
			
		||||
            auto info = validateServiceNameArg(args);
 | 
			
		||||
            if (!info.isNull) serviceManager.showLogs(info.get);
 | 
			
		||||
        } else if (subcommand == "follow") {
 | 
			
		||||
            auto info = validateServiceNameArg(args);
 | 
			
		||||
            if (!info.isNull) serviceManager.follow(info.get);
 | 
			
		||||
        } else {
 | 
			
		||||
            writeln("Unknown subcommand.");
 | 
			
		||||
            cwriteln("Unknown subcommand.".red);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /** 
 | 
			
		||||
     * Validates that a service command contains as its second argument a valid
 | 
			
		||||
     * service name.
 | 
			
		||||
     * Params:
 | 
			
		||||
     *   args = The arguments.
 | 
			
		||||
     * Returns: The service name, or null if none was found.
 | 
			
		||||
     */
 | 
			
		||||
    private Nullable!(const(ServiceInfo)) validateServiceNameArg(string[] args) {
 | 
			
		||||
        import std.string : toLower, strip;
 | 
			
		||||
        if (args.length < 2) {
 | 
			
		||||
            cwriteln("Missing required service name as argument to the service subcommand.".red);
 | 
			
		||||
            return Nullable!(const(ServiceInfo)).init;
 | 
			
		||||
        }
 | 
			
		||||
        string serviceName = args[1].strip().toLower();
 | 
			
		||||
        Nullable!(const(ServiceInfo)) info = getServiceByName(serviceName);
 | 
			
		||||
        if (info.isNull) {
 | 
			
		||||
            cwritefln("There is no service named %s.".red, serviceName.orange);
 | 
			
		||||
        }
 | 
			
		||||
        return info;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,10 +1,14 @@
 | 
			
		|||
module services;
 | 
			
		||||
 | 
			
		||||
import std.array;
 | 
			
		||||
import std.process;
 | 
			
		||||
import std.stdio;
 | 
			
		||||
import std.string;
 | 
			
		||||
import std.typecons;
 | 
			
		||||
import core.thread;
 | 
			
		||||
import core.atomic;
 | 
			
		||||
 | 
			
		||||
import consolecolors;
 | 
			
		||||
 | 
			
		||||
struct ServiceInfo {
 | 
			
		||||
    string name;
 | 
			
		||||
| 
						 | 
				
			
			@ -62,23 +66,18 @@ struct ServiceStatus {
 | 
			
		|||
class ServiceManager {
 | 
			
		||||
    private ServiceRunner[string] serviceRunners;
 | 
			
		||||
 | 
			
		||||
    public Tuple!(bool, "started", string, "msg") startService(string name) {
 | 
			
		||||
        auto info = getServiceByName(name);
 | 
			
		||||
        if (info.isNull) return tuple!("started", "msg")(false, "Invalid service name.");
 | 
			
		||||
        const ServiceInfo service = info.get();
 | 
			
		||||
    public bool startService(const ServiceInfo service) {
 | 
			
		||||
        if (service.name !in serviceRunners || !serviceRunners[service.name].isRunning) {
 | 
			
		||||
            // Start all dependencies first.
 | 
			
		||||
            foreach (string depName; service.dependencies) {
 | 
			
		||||
                auto result = startService(depName);
 | 
			
		||||
                if (!result.started) {
 | 
			
		||||
                    return tuple!("started", "msg")(
 | 
			
		||||
                        false,
 | 
			
		||||
                        format!"Couldn't start dependency \"%s\": %s"(depName, result.msg)
 | 
			
		||||
                    );
 | 
			
		||||
                bool result = startService(getServiceByName(depName).get);
 | 
			
		||||
                if (!result) {
 | 
			
		||||
                    cwritefln("Can't start %s because dependency %s couldn't be started.".red, service.name.orange, depName.orange);
 | 
			
		||||
                    return false;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            // Then start the process.
 | 
			
		||||
            writefln!"Starting service: %s"(service.name);
 | 
			
		||||
            cwritefln("Starting service %s".green, service.name.white);
 | 
			
		||||
            ProcessPipes pipes = pipeShell(
 | 
			
		||||
                service.startupCommand,
 | 
			
		||||
                Redirect.all,
 | 
			
		||||
| 
						 | 
				
			
			@ -89,23 +88,20 @@ class ServiceManager {
 | 
			
		|||
            ServiceRunner runner = new ServiceRunner(pipes);
 | 
			
		||||
            runner.start();
 | 
			
		||||
            serviceRunners[service.name] = runner;
 | 
			
		||||
            return tuple!("started", "msg")(true, "Service started.");
 | 
			
		||||
            cwritefln("Service %s started.".green, service.name.white);
 | 
			
		||||
        }
 | 
			
		||||
        return tuple!("started", "msg")(true, "Service already running.");
 | 
			
		||||
        cwritefln("Service %s is already started.".green, service.name.white);
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public Tuple!(bool, "stopped", string, "msg") stopService(string name) {
 | 
			
		||||
        auto info = getServiceByName(name);
 | 
			
		||||
        if (info.isNull) return tuple!("stopped", "msg")(false, "Invalid service name.");
 | 
			
		||||
        const ServiceInfo service = info.get();
 | 
			
		||||
    public bool stopService(const ServiceInfo service) {
 | 
			
		||||
        if (service.name in serviceRunners && serviceRunners[service.name].isRunning) {
 | 
			
		||||
            int exitStatus = serviceRunners[service.name].stopService();
 | 
			
		||||
            return tuple!("stopped", "msg")(
 | 
			
		||||
                true,
 | 
			
		||||
                format!"Service exited with status %d."(exitStatus)
 | 
			
		||||
            );
 | 
			
		||||
            cwritefln("Service %s exited with code <orange>%d</orange>.".green, service.name.white, exitStatus);
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
        return tuple!("stopped", "msg")(true, "Service already stopped.");
 | 
			
		||||
        cwritefln("Service %s already stopped.".green, service.name.white);
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public void stopAll() {
 | 
			
		||||
| 
						 | 
				
			
			@ -121,6 +117,22 @@ class ServiceManager {
 | 
			
		|||
        }
 | 
			
		||||
        return statuses;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public void showLogs(const ServiceInfo service, size_t lineCount = 50) {
 | 
			
		||||
        if (service.name in serviceRunners) {
 | 
			
		||||
            serviceRunners[service.name].showOutput(lineCount);
 | 
			
		||||
        } else {
 | 
			
		||||
            cwritefln("Service %s has not been started.".red, service.name.orange);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public void follow(const ServiceInfo service) {
 | 
			
		||||
        if (service.name in serviceRunners) {
 | 
			
		||||
            serviceRunners[service.name].setFollowing(true);
 | 
			
		||||
        } else {
 | 
			
		||||
            cwritefln("Service %s has not been started.".red, service.name.orange);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class ServiceRunner : Thread {
 | 
			
		||||
| 
						 | 
				
			
			@ -129,6 +141,8 @@ class ServiceRunner : Thread {
 | 
			
		|||
    private File processStdout;
 | 
			
		||||
    private File processStderr;
 | 
			
		||||
    public Nullable!int exitStatus;
 | 
			
		||||
    private FileGobbler stdoutGobbler;
 | 
			
		||||
    private FileGobbler stderrGobbler;
 | 
			
		||||
 | 
			
		||||
    public this(ProcessPipes pipes) {
 | 
			
		||||
        super(&this.run);
 | 
			
		||||
| 
						 | 
				
			
			@ -136,9 +150,13 @@ class ServiceRunner : Thread {
 | 
			
		|||
        this.processStdin = pipes.stdin();
 | 
			
		||||
        this.processStdout = pipes.stdout();
 | 
			
		||||
        this.processStderr = pipes.stderr();
 | 
			
		||||
        this.stdoutGobbler = new FileGobbler(pipes.stdout());
 | 
			
		||||
        this.stderrGobbler = new FileGobbler(pipes.stderr());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private void run() {
 | 
			
		||||
        this.stdoutGobbler.start();
 | 
			
		||||
        this.stderrGobbler.start();
 | 
			
		||||
        Tuple!(bool, "terminated", int, "status") result = tryWait(this.processId);
 | 
			
		||||
        while (!result.terminated) {
 | 
			
		||||
            Thread.sleep(msecs(1000));
 | 
			
		||||
| 
						 | 
				
			
			@ -148,12 +166,66 @@ class ServiceRunner : Thread {
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    public int stopService() {
 | 
			
		||||
        if (!exitStatus.isNull) return exitStatus.get();
 | 
			
		||||
 | 
			
		||||
        version(Posix) {
 | 
			
		||||
            import core.sys.posix.signal : SIGTERM;
 | 
			
		||||
            import core.sys.posix.signal : SIGINT, SIGTERM;
 | 
			
		||||
            kill(this.processId, SIGINT);
 | 
			
		||||
            kill(this.processId, SIGTERM);
 | 
			
		||||
        } else version(Windows) {
 | 
			
		||||
            kill(this.processId);
 | 
			
		||||
        }
 | 
			
		||||
        return wait(this.processId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public void showOutput(size_t lineCount = 50) {
 | 
			
		||||
        this.stdoutGobbler.showOutput(lineCount);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public void showErrorOutput(size_t lineCount = 50) {
 | 
			
		||||
        this.stderrGobbler.showOutput(lineCount);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public void setFollowing(bool following) {
 | 
			
		||||
        this.stdoutGobbler.setFollowing(following);
 | 
			
		||||
        this.stderrGobbler.setFollowing(following);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class FileGobbler : Thread {
 | 
			
		||||
    private string[] lines;
 | 
			
		||||
    private File file;
 | 
			
		||||
    private bool following;
 | 
			
		||||
 | 
			
		||||
    public this(File file) {
 | 
			
		||||
        super(&this.run);
 | 
			
		||||
        this.file = file;
 | 
			
		||||
        this.following = false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private void run() {
 | 
			
		||||
        string line;
 | 
			
		||||
        while ((line = stripRight(this.file.readln())) !is null) {
 | 
			
		||||
            if (atomicLoad(this.following)) {
 | 
			
		||||
                writeln(line);
 | 
			
		||||
            }
 | 
			
		||||
            synchronized(this) {
 | 
			
		||||
                this.lines ~= line;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public void showOutput(size_t lineCount = 50) {
 | 
			
		||||
        synchronized(this) {
 | 
			
		||||
            size_t startIdx = 0;
 | 
			
		||||
            if (lines.length > lineCount) {
 | 
			
		||||
                startIdx = lines.length - lineCount;
 | 
			
		||||
            }
 | 
			
		||||
            foreach (line; lines[startIdx .. $]) writeln(line);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public void setFollowing(bool f) {
 | 
			
		||||
        atomicStore(this.following, f);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue