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
|
```bash
|
||||||
yarn
|
yarn
|
||||||
|
@ -41,3 +45,12 @@ quasar build
|
||||||
### Customize the configuration
|
### Customize the configuration
|
||||||
|
|
||||||
See [Configuring quasar.config.js](https://v2.quasar.dev/quasar-cli-vite/quasar-config-js).
|
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 { AuthStoreType } from 'stores/auth-store';
|
||||||
import Timeout = NodeJS.Timeout;
|
import Timeout = NodeJS.Timeout;
|
||||||
import { WeightUnit } from 'src/api/main/submission';
|
import { WeightUnit } from 'src/api/main/submission';
|
||||||
|
import {Page, PaginationOptions, toQueryParams} from "src/api/main/models";
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -228,27 +229,23 @@ class AuthModule {
|
||||||
public async getFollowers(
|
public async getFollowers(
|
||||||
userId: string,
|
userId: string,
|
||||||
authStore: AuthStoreType,
|
authStore: AuthStoreType,
|
||||||
page: number,
|
paginationOptions: PaginationOptions
|
||||||
count: number
|
): Promise<Page<User>> {
|
||||||
): Promise<User[]> {
|
const config = structuredClone(authStore.axiosConfig);
|
||||||
const response = await api.get(
|
config.params = toQueryParams(paginationOptions);
|
||||||
`/auth/users/${userId}/followers?page=${page}&count=${count}`,
|
const response = await api.get(`/auth/users/${userId}/followers`, config);
|
||||||
authStore.axiosConfig
|
return response.data;
|
||||||
);
|
|
||||||
return response.data.content;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getFollowing(
|
public async getFollowing(
|
||||||
userId: string,
|
userId: string,
|
||||||
authStore: AuthStoreType,
|
authStore: AuthStoreType,
|
||||||
page: number,
|
paginationOptions: PaginationOptions
|
||||||
count: number
|
): Promise<Page<User>> {
|
||||||
): Promise<User[]> {
|
const config = structuredClone(authStore.axiosConfig);
|
||||||
const response = await api.get(
|
config.params = toQueryParams(paginationOptions);
|
||||||
`/auth/users/${userId}/following?page=${page}&count=${count}`,
|
const response = await api.get(`/auth/users/${userId}/following`, config);
|
||||||
authStore.axiosConfig
|
return response.data;
|
||||||
);
|
|
||||||
return response.data.content;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getRelationshipTo(
|
public async getRelationshipTo(
|
||||||
|
@ -283,12 +280,12 @@ class AuthModule {
|
||||||
userId: string,
|
userId: string,
|
||||||
reason: string,
|
reason: string,
|
||||||
description: string | null,
|
description: string | null,
|
||||||
authStore?: AuthStoreType
|
authStore: AuthStoreType
|
||||||
) {
|
) {
|
||||||
await api.post(
|
await api.post(
|
||||||
`/auth/users/${userId}/reports`,
|
`/auth/users/${userId}/reports`,
|
||||||
{ reason, description },
|
{ reason, description },
|
||||||
authStore?.axiosConfig
|
authStore.axiosConfig
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -52,3 +52,19 @@ export enum PaginationSortDir {
|
||||||
ASC = 'asc',
|
ASC = 'asc',
|
||||||
DESC = 'desc'
|
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>> {
|
public async getSubmissions(userId: string, authStore: AuthStoreType, paginationOptions: PaginationOptions = defaultPaginationOptions()): Promise<Page<ExerciseSubmission>> {
|
||||||
const config = structuredClone(authStore.axiosConfig);
|
const config = structuredClone(authStore.axiosConfig);
|
||||||
config.params = toQueryParams(paginationOptions);
|
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);
|
response.data.content = response.data.content.map(parseSubmission);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, Ref } from 'vue';
|
import { onMounted, ref, Ref } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import StandardCenteredPage from 'components/StandardCenteredPage.vue';
|
import StandardCenteredPage from 'components/StandardCenteredPage.vue';
|
||||||
import { getGymFromRoute, getGymRoute } from 'src/router/gym-routing';
|
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 {useAuthStore} from 'stores/auth-store';
|
||||||
import {onMounted, ref, Ref} from 'vue';
|
import {onMounted, ref, Ref} from 'vue';
|
||||||
import api from 'src/api/main';
|
import api from 'src/api/main';
|
||||||
|
import InfinitePageLoader from 'src/api/infinite-page-loader';
|
||||||
|
import {defaultPaginationOptions} from 'src/api/main/models';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
userId: string
|
userId: string
|
||||||
|
@ -23,9 +25,17 @@ interface Props {
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const followers: Ref<User[]> = ref([]);
|
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 () => {
|
onMounted(async () => {
|
||||||
followers.value = await api.auth.getFollowers(props.userId, authStore, 0, 10);
|
loader.registerWindowScrollListener();
|
||||||
|
await loader.setPagination(defaultPaginationOptions());
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -13,6 +13,8 @@ import {User} from 'src/api/main/auth';
|
||||||
import {useAuthStore} from 'stores/auth-store';
|
import {useAuthStore} from 'stores/auth-store';
|
||||||
import {onMounted, ref, Ref} from 'vue';
|
import {onMounted, ref, Ref} from 'vue';
|
||||||
import api from 'src/api/main';
|
import api from 'src/api/main';
|
||||||
|
import InfinitePageLoader from 'src/api/infinite-page-loader';
|
||||||
|
import {defaultPaginationOptions} from 'src/api/main/models';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
@ -20,14 +22,16 @@ interface Props {
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const following: Ref<User[]> = ref([]);
|
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 () => {
|
onMounted(async () => {
|
||||||
following.value = await api.auth.getFollowing(
|
loader.registerWindowScrollListener();
|
||||||
props.userId,
|
await loader.setPagination(defaultPaginationOptions());
|
||||||
authStore,
|
|
||||||
0,
|
|
||||||
10
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
</script>
|
</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>
|
<template>
|
||||||
<q-page>
|
<q-page>
|
||||||
<StandardCenteredPage v-if="profile">
|
<StandardCenteredPage v-if="profile">
|
||||||
|
|
|
@ -1,19 +1,14 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div v-if="loadedSubmissions.length > 0">
|
<div v-if="submissions.length > 0">
|
||||||
<q-list separator>
|
<q-list separator>
|
||||||
<ExerciseSubmissionListItem
|
<ExerciseSubmissionListItem
|
||||||
v-for="sub in loadedSubmissions"
|
v-for="sub in submissions"
|
||||||
:submission="sub"
|
:submission="sub"
|
||||||
:key="sub.id"
|
:key="sub.id"
|
||||||
:show-name="false"
|
:show-name="false"
|
||||||
/>
|
/>
|
||||||
</q-list>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -22,12 +17,13 @@
|
||||||
import {useI18n} from 'vue-i18n';
|
import {useI18n} from 'vue-i18n';
|
||||||
import {useQuasar} from 'quasar';
|
import {useQuasar} from 'quasar';
|
||||||
import {useAuthStore} from 'stores/auth-store';
|
import {useAuthStore} from 'stores/auth-store';
|
||||||
import {nextTick, onMounted, ref, Ref} from 'vue';
|
import {onMounted, ref, Ref} from 'vue';
|
||||||
import {ExerciseSubmission} from 'src/api/main/submission';
|
|
||||||
import api from 'src/api/main';
|
import api from 'src/api/main';
|
||||||
import ExerciseSubmissionListItem from 'components/ExerciseSubmissionListItem.vue';
|
import ExerciseSubmissionListItem from 'components/ExerciseSubmissionListItem.vue';
|
||||||
import {showApiErrorToast} from 'src/utils';
|
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 {
|
interface Props {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
@ -38,24 +34,10 @@ const i18n = useI18n();
|
||||||
const quasar = useQuasar();
|
const quasar = useQuasar();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
const lastSubmissionsPage: Ref<Page<ExerciseSubmission> | undefined> = ref();
|
const submissions: Ref<ExerciseSubmission[]> = ref([]);
|
||||||
const loadedSubmissions: Ref<ExerciseSubmission[]> = ref([]);
|
const loader = new InfinitePageLoader(submissions, async paginationOptions => {
|
||||||
const paginationOptions: PaginationOptions = {page: 0, size: 10};
|
|
||||||
onMounted(async () => {
|
|
||||||
resetPagination();
|
|
||||||
await loadNextPage(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadNextPage(scroll: boolean) {
|
|
||||||
try {
|
try {
|
||||||
lastSubmissionsPage.value = await api.users.getSubmissions(props.userId, authStore, paginationOptions);
|
return 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' });
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
showApiErrorToast(i18n, quasar);
|
showApiErrorToast(i18n, quasar);
|
||||||
|
@ -63,13 +45,12 @@ async function loadNextPage(scroll: boolean) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
function resetPagination() {
|
onMounted(async () => {
|
||||||
paginationOptions.page = 0;
|
loader.registerWindowScrollListener();
|
||||||
paginationOptions.size = 10;
|
await loader.setPagination(PaginationHelpers.sortedDescBy('performedAt'));
|
||||||
paginationOptions.sort = { propertyName: 'performedAt', sortDir: PaginationSortDir.DESC };
|
});
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
@ -17,3 +17,16 @@ export function showInfoToast(quasar: QVueGlobals, translatedMessage: string) {
|
||||||
position: 'top'
|
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"
|
"Andrew Lalis"
|
||||||
],
|
],
|
||||||
"copyright": "Copyright © 2023, Andrew Lalis",
|
"copyright": "Copyright © 2023, Andrew Lalis",
|
||||||
|
"dependencies": {
|
||||||
|
"console-colors": "~>1.1.0"
|
||||||
|
},
|
||||||
"description": "CLI for developing Gymboard",
|
"description": "CLI for developing Gymboard",
|
||||||
"license": "proprietary",
|
"license": "proprietary",
|
||||||
"name": "gymboard-cli"
|
"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 cli;
|
||||||
import services;
|
import services;
|
||||||
|
|
||||||
|
import consolecolors;
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
ServiceManager serviceManager = new ServiceManager();
|
ServiceManager serviceManager = new ServiceManager();
|
||||||
CliHandler cliHandler = new CliHandler();
|
CliHandler cliHandler = new CliHandler();
|
||||||
cliHandler.register("service", new ServiceCommand(serviceManager));
|
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) {
|
while (!cliHandler.shouldExit) {
|
||||||
|
cwrite("> ".blue);
|
||||||
cliHandler.readAndHandleCommand();
|
cliHandler.readAndHandleCommand();
|
||||||
}
|
}
|
||||||
serviceManager.stopAll();
|
serviceManager.stopAll();
|
||||||
writeln("Goodbye!");
|
cwriteln("Goodbye!".green);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,9 @@ module cli;
|
||||||
import std.stdio;
|
import std.stdio;
|
||||||
import std.string;
|
import std.string;
|
||||||
import std.uni;
|
import std.uni;
|
||||||
|
import std.typecons;
|
||||||
|
|
||||||
|
import consolecolors;
|
||||||
|
|
||||||
interface CliCommand {
|
interface CliCommand {
|
||||||
void handle(string[] args);
|
void handle(string[] args);
|
||||||
|
@ -27,7 +30,7 @@ class CliHandler {
|
||||||
} else if (command in commands) {
|
} else if (command in commands) {
|
||||||
commands[command].handle(commandAndArgs.length > 1 ? commandAndArgs[1 .. $] : []);
|
commands[command].handle(commandAndArgs.length > 1 ? commandAndArgs[1 .. $] : []);
|
||||||
} else {
|
} else {
|
||||||
writefln!"Unknown command \"%s\"."(command);
|
cwritefln("Unknown command: %s".red, command.orange);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -54,34 +57,53 @@ class ServiceCommand : CliCommand {
|
||||||
|
|
||||||
void handle(string[] args) {
|
void handle(string[] args) {
|
||||||
if (args.length == 0) {
|
if (args.length == 0) {
|
||||||
writeln("Missing subcommand.");
|
cwriteln("Missing subcommand.".red);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
string subcommand = args[0];
|
string subcommand = args[0];
|
||||||
if (subcommand == "status") {
|
if (subcommand == "status") {
|
||||||
auto statuses = serviceManager.getStatus();
|
auto statuses = serviceManager.getStatus();
|
||||||
if (statuses.length == 0) {
|
if (statuses.length == 0) {
|
||||||
writeln("No services running.");
|
cwriteln("No services running.".orange);
|
||||||
}
|
}
|
||||||
foreach (status; statuses) {
|
foreach (status; statuses) {
|
||||||
writefln!"%s: Running = %s, Exit code = %s"(status.name, status.running, status.exitCode);
|
writefln!"%s: Running = %s, Exit code = %s"(status.name, status.running, status.exitCode);
|
||||||
}
|
}
|
||||||
} else if (subcommand == "start") {
|
} else if (subcommand == "start") {
|
||||||
if (args.length < 2) {
|
auto info = validateServiceNameArg(args);
|
||||||
writeln("Missing service name.");
|
if (!info.isNull) serviceManager.startService(info.get);
|
||||||
return;
|
|
||||||
}
|
|
||||||
auto result = serviceManager.startService(args[1]);
|
|
||||||
writeln(result.msg);
|
|
||||||
} else if (subcommand == "stop") {
|
} else if (subcommand == "stop") {
|
||||||
if (args.length < 2) {
|
auto info = validateServiceNameArg(args);
|
||||||
writeln("Missing service name.");
|
if (!info.isNull) serviceManager.stopService(info.get);
|
||||||
return;
|
} else if (subcommand == "logs") {
|
||||||
}
|
auto info = validateServiceNameArg(args);
|
||||||
auto result = serviceManager.stopService(args[1]);
|
if (!info.isNull) serviceManager.showLogs(info.get);
|
||||||
writeln(result.msg);
|
} else if (subcommand == "follow") {
|
||||||
|
auto info = validateServiceNameArg(args);
|
||||||
|
if (!info.isNull) serviceManager.follow(info.get);
|
||||||
} else {
|
} 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;
|
module services;
|
||||||
|
|
||||||
|
import std.array;
|
||||||
import std.process;
|
import std.process;
|
||||||
import std.stdio;
|
import std.stdio;
|
||||||
import std.string;
|
import std.string;
|
||||||
import std.typecons;
|
import std.typecons;
|
||||||
import core.thread;
|
import core.thread;
|
||||||
|
import core.atomic;
|
||||||
|
|
||||||
|
import consolecolors;
|
||||||
|
|
||||||
struct ServiceInfo {
|
struct ServiceInfo {
|
||||||
string name;
|
string name;
|
||||||
|
@ -62,23 +66,18 @@ struct ServiceStatus {
|
||||||
class ServiceManager {
|
class ServiceManager {
|
||||||
private ServiceRunner[string] serviceRunners;
|
private ServiceRunner[string] serviceRunners;
|
||||||
|
|
||||||
public Tuple!(bool, "started", string, "msg") startService(string name) {
|
public bool startService(const ServiceInfo service) {
|
||||||
auto info = getServiceByName(name);
|
|
||||||
if (info.isNull) return tuple!("started", "msg")(false, "Invalid service name.");
|
|
||||||
const ServiceInfo service = info.get();
|
|
||||||
if (service.name !in serviceRunners || !serviceRunners[service.name].isRunning) {
|
if (service.name !in serviceRunners || !serviceRunners[service.name].isRunning) {
|
||||||
// Start all dependencies first.
|
// Start all dependencies first.
|
||||||
foreach (string depName; service.dependencies) {
|
foreach (string depName; service.dependencies) {
|
||||||
auto result = startService(depName);
|
bool result = startService(getServiceByName(depName).get);
|
||||||
if (!result.started) {
|
if (!result) {
|
||||||
return tuple!("started", "msg")(
|
cwritefln("Can't start %s because dependency %s couldn't be started.".red, service.name.orange, depName.orange);
|
||||||
false,
|
return false;
|
||||||
format!"Couldn't start dependency \"%s\": %s"(depName, result.msg)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Then start the process.
|
// Then start the process.
|
||||||
writefln!"Starting service: %s"(service.name);
|
cwritefln("Starting service %s".green, service.name.white);
|
||||||
ProcessPipes pipes = pipeShell(
|
ProcessPipes pipes = pipeShell(
|
||||||
service.startupCommand,
|
service.startupCommand,
|
||||||
Redirect.all,
|
Redirect.all,
|
||||||
|
@ -89,23 +88,20 @@ class ServiceManager {
|
||||||
ServiceRunner runner = new ServiceRunner(pipes);
|
ServiceRunner runner = new ServiceRunner(pipes);
|
||||||
runner.start();
|
runner.start();
|
||||||
serviceRunners[service.name] = runner;
|
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) {
|
public bool stopService(const ServiceInfo service) {
|
||||||
auto info = getServiceByName(name);
|
|
||||||
if (info.isNull) return tuple!("stopped", "msg")(false, "Invalid service name.");
|
|
||||||
const ServiceInfo service = info.get();
|
|
||||||
if (service.name in serviceRunners && serviceRunners[service.name].isRunning) {
|
if (service.name in serviceRunners && serviceRunners[service.name].isRunning) {
|
||||||
int exitStatus = serviceRunners[service.name].stopService();
|
int exitStatus = serviceRunners[service.name].stopService();
|
||||||
return tuple!("stopped", "msg")(
|
cwritefln("Service %s exited with code <orange>%d</orange>.".green, service.name.white, exitStatus);
|
||||||
true,
|
return true;
|
||||||
format!"Service exited with status %d."(exitStatus)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return tuple!("stopped", "msg")(true, "Service already stopped.");
|
cwritefln("Service %s already stopped.".green, service.name.white);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void stopAll() {
|
public void stopAll() {
|
||||||
|
@ -121,6 +117,22 @@ class ServiceManager {
|
||||||
}
|
}
|
||||||
return statuses;
|
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 {
|
class ServiceRunner : Thread {
|
||||||
|
@ -129,6 +141,8 @@ class ServiceRunner : Thread {
|
||||||
private File processStdout;
|
private File processStdout;
|
||||||
private File processStderr;
|
private File processStderr;
|
||||||
public Nullable!int exitStatus;
|
public Nullable!int exitStatus;
|
||||||
|
private FileGobbler stdoutGobbler;
|
||||||
|
private FileGobbler stderrGobbler;
|
||||||
|
|
||||||
public this(ProcessPipes pipes) {
|
public this(ProcessPipes pipes) {
|
||||||
super(&this.run);
|
super(&this.run);
|
||||||
|
@ -136,9 +150,13 @@ class ServiceRunner : Thread {
|
||||||
this.processStdin = pipes.stdin();
|
this.processStdin = pipes.stdin();
|
||||||
this.processStdout = pipes.stdout();
|
this.processStdout = pipes.stdout();
|
||||||
this.processStderr = pipes.stderr();
|
this.processStderr = pipes.stderr();
|
||||||
|
this.stdoutGobbler = new FileGobbler(pipes.stdout());
|
||||||
|
this.stderrGobbler = new FileGobbler(pipes.stderr());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void run() {
|
private void run() {
|
||||||
|
this.stdoutGobbler.start();
|
||||||
|
this.stderrGobbler.start();
|
||||||
Tuple!(bool, "terminated", int, "status") result = tryWait(this.processId);
|
Tuple!(bool, "terminated", int, "status") result = tryWait(this.processId);
|
||||||
while (!result.terminated) {
|
while (!result.terminated) {
|
||||||
Thread.sleep(msecs(1000));
|
Thread.sleep(msecs(1000));
|
||||||
|
@ -148,12 +166,66 @@ class ServiceRunner : Thread {
|
||||||
}
|
}
|
||||||
|
|
||||||
public int stopService() {
|
public int stopService() {
|
||||||
|
if (!exitStatus.isNull) return exitStatus.get();
|
||||||
|
|
||||||
version(Posix) {
|
version(Posix) {
|
||||||
import core.sys.posix.signal : SIGTERM;
|
import core.sys.posix.signal : SIGINT, SIGTERM;
|
||||||
|
kill(this.processId, SIGINT);
|
||||||
kill(this.processId, SIGTERM);
|
kill(this.processId, SIGTERM);
|
||||||
} else version(Windows) {
|
} else version(Windows) {
|
||||||
kill(this.processId);
|
kill(this.processId);
|
||||||
}
|
}
|
||||||
return wait(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