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