Added more docs

This commit is contained in:
Andrew Lalis 2023-03-26 13:33:10 +02:00
parent f36a1d8912
commit 90a2346cc9
16 changed files with 327 additions and 105 deletions

View File

@ -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.

View File

@ -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;
}
};

View File

@ -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
); );
} }
} }

View File

@ -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);
}
}

View File

@ -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;
} }

View File

@ -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';

View File

@ -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>

View File

@ -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>

View File

@ -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">

View File

@ -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>

View File

@ -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();
}

View File

@ -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"

View File

@ -0,0 +1,6 @@
{
"fileVersion": 1,
"versions": {
"console-colors": "1.1.0"
}
}

View File

@ -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("&gt; ".blue);
cliHandler.readAndHandleCommand(); cliHandler.readAndHandleCommand();
} }
serviceManager.stopAll(); serviceManager.stopAll();
writeln("Goodbye!"); cwriteln("Goodbye!".green);
} }

View File

@ -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;
}
} }

View File

@ -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);
}
} }