mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42da2c3397 | ||
|
|
62ac23cb1a | ||
|
|
c0c678ac0d | ||
|
|
c036b77331 | ||
|
|
7b467807d9 | ||
|
|
2e8b088c59 | ||
|
|
e69a419551 | ||
|
|
a10d0569af | ||
|
|
237b3832bb | ||
|
|
eefa7c8ca8 | ||
|
|
fc0a0615cb | ||
|
|
3a61d68dc1 | ||
|
|
0121195e75 | ||
|
|
0c054bdcf2 | ||
|
|
96f818cb04 | ||
|
|
31ca0419f5 | ||
|
|
78e35222f8 | ||
|
|
c5b854adb3 | ||
|
|
9f374c7716 | ||
|
|
ce8e503faa | ||
|
|
79f914d4b6 | ||
|
|
c4757ee8a9 | ||
|
|
c0212ec836 | ||
|
|
8f0c9afa1a |
7
.env.ci
7
.env.ci
@@ -31,12 +31,7 @@ REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_HOST=mailpit
|
||||
MAIL_PORT=1025
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_ENCRYPTION=null
|
||||
MAIL_MAILER=log
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
APP_FORCE_HTTPS=true
|
||||
SESSION_SECURE_COOKIE=true
|
||||
OCTANE_SERVER=swoole
|
||||
OCTANE_SERVER=frankenphp
|
||||
PAGINATION_PER_PAGE_DEFAULT=500
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
|
||||
8
.github/workflows/build-private.yml
vendored
8
.github/workflows/build-private.yml
vendored
@@ -15,6 +15,8 @@ name: Build - Private
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: "Check out code"
|
||||
uses: actions/checkout@v4
|
||||
@@ -69,7 +71,7 @@ jobs:
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
extensions: mbstring, dom, fileinfo, pgsql, swoole
|
||||
extensions: mbstring, dom, fileinfo, pgsql
|
||||
|
||||
- name: "Install dependencies"
|
||||
uses: php-actions/composer@v6
|
||||
@@ -116,9 +118,11 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: "Build and push"
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
build-args: |
|
||||
DOCKER_FILES_BASE_PATH=docker/prod/
|
||||
file: docker/prod/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
|
||||
6
.github/workflows/build-public.yml
vendored
6
.github/workflows/build-public.yml
vendored
@@ -15,6 +15,8 @@ name: Build - Public
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: "Check out code"
|
||||
uses: actions/checkout@v4
|
||||
@@ -62,10 +64,12 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: "Build and push"
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/prod/Dockerfile
|
||||
build-args: |
|
||||
DOCKER_FILES_BASE_PATH=docker/prod/
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
|
||||
1
.github/workflows/generate-api-docs.yml
vendored
1
.github/workflows/generate-api-docs.yml
vendored
@@ -6,6 +6,7 @@ on:
|
||||
jobs:
|
||||
api_docs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
services:
|
||||
pgsql_test:
|
||||
|
||||
2
.github/workflows/npm-build.yml
vendored
2
.github/workflows/npm-build.yml
vendored
@@ -4,8 +4,8 @@ on: [push]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
|
||||
2
.github/workflows/npm-lint.yml
vendored
2
.github/workflows/npm-lint.yml
vendored
@@ -4,8 +4,8 @@ on: [push]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
|
||||
2
.github/workflows/npm-typecheck.yml
vendored
2
.github/workflows/npm-typecheck.yml
vendored
@@ -4,8 +4,8 @@ on: [push]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
|
||||
1
.github/workflows/phpstan.yml
vendored
1
.github/workflows/phpstan.yml
vendored
@@ -3,6 +3,7 @@ on: push
|
||||
jobs:
|
||||
phpstan:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
|
||||
1
.github/workflows/phpunit.yml
vendored
1
.github/workflows/phpunit.yml
vendored
@@ -3,6 +3,7 @@ on: push
|
||||
jobs:
|
||||
phpunit:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
services:
|
||||
pgsql_test:
|
||||
|
||||
2
.github/workflows/pint.yml
vendored
2
.github/workflows/pint.yml
vendored
@@ -3,6 +3,8 @@ on: push
|
||||
jobs:
|
||||
pint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
|
||||
6
.github/workflows/playwright.yml
vendored
6
.github/workflows/playwright.yml
vendored
@@ -1,10 +1,10 @@
|
||||
name: Playwright Tests
|
||||
on:
|
||||
workflow_dispatch:
|
||||
on: [push]
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
services:
|
||||
mailpit:
|
||||
image: 'axllent/mailpit:latest'
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -34,3 +34,10 @@ yarn-error.log
|
||||
/_ide_helper.php
|
||||
/.phpstorm.meta.php
|
||||
/.rnd
|
||||
|
||||
/caddy
|
||||
/frankenphp
|
||||
/public/frankenphp-worker.php
|
||||
/data
|
||||
/config/caddy
|
||||
/config/composer
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"require": {
|
||||
"php": "8.3.*",
|
||||
"ext-zip": "*",
|
||||
"brick/money": "^0.8.1",
|
||||
"brick/money": "^0.9.0",
|
||||
"dedoc/scramble": "dev-main",
|
||||
"filament/filament": "^3.2",
|
||||
"flowframe/laravel-trend": "^0.2.0",
|
||||
@@ -22,12 +22,12 @@
|
||||
"laravel/passport": "^12.0",
|
||||
"laravel/tinker": "^2.8",
|
||||
"league/flysystem-aws-s3-v3": "^3.0",
|
||||
"nwidart/laravel-modules": "dev-feature/fixed_path",
|
||||
"nwidart/laravel-modules": "^11.0.11",
|
||||
"pxlrbt/filament-environment-indicator": "^2.0",
|
||||
"spatie/temporary-directory": "^2.2",
|
||||
"stechstudio/filament-impersonate": "^3.8",
|
||||
"tightenco/ziggy": "^2.1.0",
|
||||
"tpetry/laravel-postgresql-enhanced": "^0.38.0",
|
||||
"tpetry/laravel-postgresql-enhanced": "^0.39.0",
|
||||
"wikimedia/composer-merge-plugin": "^2.1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
@@ -102,6 +102,9 @@
|
||||
"ide-helper": [
|
||||
"@php artisan ide-helper:generate",
|
||||
"@php artisan ide-helper:meta"
|
||||
],
|
||||
"refresh-schema-dump": [
|
||||
"@php artisan schema:dump --database=\"pgsql_test\""
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
@@ -115,10 +118,6 @@
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://github.com/korridor/scramble"
|
||||
},
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://github.com/korridor/laravel-modules"
|
||||
}
|
||||
],
|
||||
"config": {
|
||||
|
||||
660
composer.lock
generated
660
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -14,10 +14,13 @@ use Laravel\Octane\Events\WorkerErrorOccurred;
|
||||
use Laravel\Octane\Events\WorkerStarting;
|
||||
use Laravel\Octane\Events\WorkerStopping;
|
||||
use Laravel\Octane\Listeners\CloseMonologHandlers;
|
||||
use Laravel\Octane\Listeners\CollectGarbage;
|
||||
use Laravel\Octane\Listeners\DisconnectFromDatabases;
|
||||
use Laravel\Octane\Listeners\EnsureUploadedFilesAreValid;
|
||||
use Laravel\Octane\Listeners\EnsureUploadedFilesCanBeMoved;
|
||||
use Laravel\Octane\Listeners\FlushOnce;
|
||||
use Laravel\Octane\Listeners\FlushTemporaryContainerInstances;
|
||||
use Laravel\Octane\Listeners\FlushUploadedFiles;
|
||||
use Laravel\Octane\Listeners\ReportException;
|
||||
use Laravel\Octane\Listeners\StopWorkerIfNecessary;
|
||||
use Laravel\Octane\Octane;
|
||||
@@ -37,7 +40,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'server' => env('OCTANE_SERVER', 'swoole'),
|
||||
'server' => env('OCTANE_SERVER', 'frankenphp'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
@@ -130,7 +128,7 @@ return [
|
||||
|
||||
'cookie' => env(
|
||||
'SESSION_COOKIE',
|
||||
Str::slug(env('APP_NAME', 'laravel'), '_').'_session'
|
||||
'solidtime_session'
|
||||
),
|
||||
|
||||
/*
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
--
|
||||
|
||||
-- Dumped from database version 15.6 (Debian 15.6-1.pgdg120+2)
|
||||
-- Dumped by pg_dump version 15.6 (Ubuntu 15.6-1.pgdg22.04+1)
|
||||
-- Dumped by pg_dump version 15.7 (Ubuntu 15.7-1.pgdg22.04+1)
|
||||
|
||||
SET statement_timeout = 0;
|
||||
SET lock_timeout = 0;
|
||||
@@ -947,7 +947,7 @@ ALTER TABLE ONLY public.clients
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.organization_invitations
|
||||
ADD CONSTRAINT organization_invitations_organization_id_foreign FOREIGN KEY (organization_id) REFERENCES public.organizations(id) ON DELETE CASCADE;
|
||||
ADD CONSTRAINT organization_invitations_organization_id_foreign FOREIGN KEY (organization_id) REFERENCES public.organizations(id) ON UPDATE CASCADE ON DELETE RESTRICT;
|
||||
|
||||
|
||||
--
|
||||
@@ -955,7 +955,7 @@ ALTER TABLE ONLY public.organization_invitations
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.project_members
|
||||
ADD CONSTRAINT project_members_member_id_foreign FOREIGN KEY (member_id) REFERENCES public.members(id) ON UPDATE CASCADE ON DELETE CASCADE;
|
||||
ADD CONSTRAINT project_members_member_id_foreign FOREIGN KEY (member_id) REFERENCES public.members(id) ON UPDATE CASCADE ON DELETE RESTRICT;
|
||||
|
||||
|
||||
--
|
||||
@@ -1019,7 +1019,7 @@ ALTER TABLE ONLY public.tasks
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.time_entries
|
||||
ADD CONSTRAINT time_entries_client_id_foreign FOREIGN KEY (client_id) REFERENCES public.clients(id) ON UPDATE CASCADE ON DELETE CASCADE;
|
||||
ADD CONSTRAINT time_entries_client_id_foreign FOREIGN KEY (client_id) REFERENCES public.clients(id) ON UPDATE CASCADE ON DELETE RESTRICT;
|
||||
|
||||
|
||||
--
|
||||
@@ -1027,7 +1027,7 @@ ALTER TABLE ONLY public.time_entries
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.time_entries
|
||||
ADD CONSTRAINT time_entries_member_id_foreign FOREIGN KEY (member_id) REFERENCES public.members(id) ON UPDATE CASCADE ON DELETE CASCADE;
|
||||
ADD CONSTRAINT time_entries_member_id_foreign FOREIGN KEY (member_id) REFERENCES public.members(id) ON UPDATE CASCADE ON DELETE RESTRICT;
|
||||
|
||||
|
||||
--
|
||||
@@ -1071,7 +1071,7 @@ ALTER TABLE ONLY public.time_entries
|
||||
--
|
||||
|
||||
-- Dumped from database version 15.6 (Debian 15.6-1.pgdg120+2)
|
||||
-- Dumped by pg_dump version 15.6 (Ubuntu 15.6-1.pgdg22.04+1)
|
||||
-- Dumped by pg_dump version 15.7 (Ubuntu 15.7-1.pgdg22.04+1)
|
||||
|
||||
SET statement_timeout = 0;
|
||||
SET lock_timeout = 0;
|
||||
@@ -1097,30 +1097,33 @@ COPY public.migrations (id, migration, batch) FROM stdin;
|
||||
6 2016_06_01_000003_create_oauth_refresh_tokens_table 1
|
||||
7 2016_06_01_000004_create_oauth_clients_table 1
|
||||
8 2016_06_01_000005_create_oauth_personal_access_clients_table 1
|
||||
9 2019_05_03_000001_create_customers_table 1
|
||||
10 2019_05_03_000002_create_subscriptions_table 1
|
||||
11 2019_05_03_000003_create_subscription_items_table 1
|
||||
12 2019_05_03_000004_create_transactions_table 1
|
||||
13 2019_08_19_000000_create_failed_jobs_table 1
|
||||
14 2019_12_14_000001_create_personal_access_tokens_table 1
|
||||
15 2020_05_21_100000_create_organizations_table 1
|
||||
16 2020_05_21_200000_create_organization_user_table 1
|
||||
17 2020_05_21_300000_create_organization_invitations_table 1
|
||||
18 2024_01_16_161030_create_sessions_table 1
|
||||
19 2024_01_20_110218_create_clients_table 1
|
||||
20 2024_01_20_110439_create_projects_table 1
|
||||
21 2024_01_20_110444_create_tasks_table 1
|
||||
22 2024_01_20_110452_create_tags_table 1
|
||||
23 2024_01_20_110837_create_time_entries_table 1
|
||||
24 2024_03_26_171253_create_project_members_table 1
|
||||
25 2024_04_11_150130_create_jobs_table 1
|
||||
26 2024_04_12_095010_create_cache_table 1
|
||||
27 2024_05_07_134711_move_from_user_id_to_member_id_in_project_members_table 1
|
||||
28 2024_05_07_141842_move_from_user_id_to_member_id_in_time_entries_table 1
|
||||
29 2024_05_13_171020_rename_table_organization_user_to_members 1
|
||||
31 2024_05_22_151226_add_client_id_to_time_entries_table 2
|
||||
36 2024_05_30_175801_add_is_billable_column_to_projects_table 3
|
||||
37 2024_05_30_175825_add_is_imported_column_to_time_entries_table 3
|
||||
9 2018_08_08_100000_create_telescope_entries_table 1
|
||||
10 2019_05_03_000001_create_customers_table 1
|
||||
11 2019_05_03_000002_create_subscriptions_table 1
|
||||
12 2019_05_03_000003_create_subscription_items_table 1
|
||||
13 2019_05_03_000004_create_transactions_table 1
|
||||
14 2019_08_19_000000_create_failed_jobs_table 1
|
||||
15 2019_12_14_000001_create_personal_access_tokens_table 1
|
||||
16 2020_05_21_100000_create_organizations_table 1
|
||||
17 2020_05_21_200000_create_organization_user_table 1
|
||||
18 2020_05_21_300000_create_organization_invitations_table 1
|
||||
19 2024_01_16_161030_create_sessions_table 1
|
||||
20 2024_01_20_110218_create_clients_table 1
|
||||
21 2024_01_20_110439_create_projects_table 1
|
||||
22 2024_01_20_110444_create_tasks_table 1
|
||||
23 2024_01_20_110452_create_tags_table 1
|
||||
24 2024_01_20_110837_create_time_entries_table 1
|
||||
25 2024_03_26_171253_create_project_members_table 1
|
||||
26 2024_04_11_150130_create_jobs_table 1
|
||||
27 2024_04_12_095010_create_cache_table 1
|
||||
28 2024_05_07_134711_move_from_user_id_to_member_id_in_project_members_table 1
|
||||
29 2024_05_07_141842_move_from_user_id_to_member_id_in_time_entries_table 1
|
||||
30 2024_05_13_171020_rename_table_organization_user_to_members 1
|
||||
31 2024_05_22_151226_add_client_id_to_time_entries_table 1
|
||||
32 2024_05_30_175801_add_is_billable_column_to_projects_table 1
|
||||
33 2024_05_30_175825_add_is_imported_column_to_time_entries_table 1
|
||||
34 2024_06_07_113443_change_member_id_foreign_keys_to_restrict_on_delete 1
|
||||
35 2024_06_10_161831_reset_billable_rates_with_zero_as_value 1
|
||||
\.
|
||||
|
||||
|
||||
@@ -1128,7 +1131,7 @@ COPY public.migrations (id, migration, batch) FROM stdin;
|
||||
-- Name: migrations_id_seq; Type: SEQUENCE SET; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
SELECT pg_catalog.setval('public.migrations_id_seq', 37, true);
|
||||
SELECT pg_catalog.setval('public.migrations_id_seq', 35, true);
|
||||
|
||||
|
||||
--
|
||||
@@ -28,7 +28,9 @@ services:
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
environment:
|
||||
SUPERVISOR_PHP_COMMAND: "/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan octane:start --server=swoole --watch --host=0.0.0.0 --port=80"
|
||||
SUPERVISOR_PHP_COMMAND: "/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan octane:start --server=frankenphp --host=0.0.0.0 --admin-port=2019 --port=80 --watch"
|
||||
XDG_CONFIG_HOME: /var/www/html/config
|
||||
XDG_DATA_HOME: /var/www/html/data
|
||||
WWWUSER: '${WWWUSER}'
|
||||
LARAVEL_SAIL: 1
|
||||
XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'
|
||||
@@ -107,7 +109,7 @@ services:
|
||||
- sail
|
||||
- reverse-proxy
|
||||
playwright:
|
||||
image: mcr.microsoft.com/playwright:v1.42.1-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.44.1-jammy
|
||||
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
|
||||
working_dir: /src
|
||||
extra_hosts:
|
||||
|
||||
@@ -1,120 +1,178 @@
|
||||
# Accepted values: 8.3 - 8.2
|
||||
ARG PHP_VERSION=8.3
|
||||
|
||||
ARG DOCKER_FILES_BASE_PATH="docker/prod"
|
||||
ARG FRANKENPHP_VERSION=latest
|
||||
|
||||
ARG COMPOSER_VERSION=latest
|
||||
|
||||
ARG DOCKER_FILES_BASE_PATH="docker/prod/"
|
||||
|
||||
###########################################
|
||||
# Build frontend assets with NPM
|
||||
###########################################
|
||||
|
||||
#ARG NODE_VERSION=20-alpine
|
||||
#
|
||||
#FROM node:${NODE_VERSION} AS build
|
||||
#
|
||||
#ENV ROOT=/var/www/html
|
||||
#
|
||||
#WORKDIR ${ROOT}
|
||||
#
|
||||
#RUN npm config set update-notifier false && npm set progress=false
|
||||
#
|
||||
#COPY package*.json ./
|
||||
#
|
||||
#RUN if [ -f $ROOT/package-lock.json ]; \
|
||||
# then \
|
||||
# npm ci --loglevel=error --no-audit; \
|
||||
# else \
|
||||
# npm install --loglevel=error --no-audit; \
|
||||
# fi
|
||||
#
|
||||
#COPY . .
|
||||
#
|
||||
#RUN npm run build
|
||||
|
||||
###########################################
|
||||
|
||||
FROM composer:${COMPOSER_VERSION} AS vendor
|
||||
|
||||
FROM php:${PHP_VERSION}-cli-bookworm AS base
|
||||
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION}
|
||||
|
||||
ARG DOCKER_FILES_BASE_PATH
|
||||
|
||||
LABEL maintainer="solidtime <hello@solidtime.io>"
|
||||
LABEL org.opencontainers.image.title="solidtime"
|
||||
LABEL org.opencontainers.image.description="solidtime is a modern open source timetracker for Freelancers and Agencies"
|
||||
LABEL org.opencontainers.image.description="solidtime is a modern open source timetracker for freelancers and agencies"
|
||||
LABEL org.opencontainers.image.source="https://github.com/solidtime-io/solidtime"
|
||||
LABEL org.opencontainers.image.licenses="AGPL"
|
||||
|
||||
ARG WWWUSER=1000
|
||||
ARG WWWGROUP=1000
|
||||
ARG TZ=UTC
|
||||
ARG APP_DIR=/var/www/html
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
TERM=xterm-color \
|
||||
WITH_HORIZON=false \
|
||||
WITH_SCHEDULER=false \
|
||||
OCTANE_SERVER=swoole \
|
||||
USER=octane \
|
||||
ROOT=/var/www/html \
|
||||
COMPOSER_FUND=0 \
|
||||
COMPOSER_MAX_PARALLEL_HTTP=24
|
||||
TERM=xterm-color \
|
||||
WITH_HORIZON=false \
|
||||
WITH_SCHEDULER=false \
|
||||
OCTANE_SERVER=frankenphp \
|
||||
USER=octane \
|
||||
ROOT=${APP_DIR} \
|
||||
COMPOSER_FUND=0 \
|
||||
COMPOSER_MAX_PARALLEL_HTTP=24 \
|
||||
XDG_CONFIG_HOME=${APP_DIR}/.config \
|
||||
XDG_DATA_HOME=${APP_DIR}/.data
|
||||
|
||||
WORKDIR ${ROOT}
|
||||
|
||||
SHELL ["/bin/bash", "-eou", "pipefail", "-c"]
|
||||
|
||||
RUN ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \
|
||||
&& echo ${TZ} > /etc/timezone
|
||||
|
||||
ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
|
||||
&& echo ${TZ} > /etc/timezone
|
||||
|
||||
RUN apt-get update; \
|
||||
apt-get upgrade -yqq; \
|
||||
apt-get install -yqq --no-install-recommends --show-progress \
|
||||
apt-utils \
|
||||
curl \
|
||||
wget \
|
||||
nano \
|
||||
ncdu \
|
||||
ca-certificates \
|
||||
supervisor \
|
||||
libsodium-dev \
|
||||
# Install PHP extensions
|
||||
&& install-php-extensions \
|
||||
bz2 \
|
||||
pcntl \
|
||||
mbstring \
|
||||
bcmath \
|
||||
sockets \
|
||||
pgsql \
|
||||
pdo_pgsql \
|
||||
opcache \
|
||||
exif \
|
||||
pdo_mysql \
|
||||
zip \
|
||||
intl \
|
||||
gd \
|
||||
redis \
|
||||
rdkafka \
|
||||
memcached \
|
||||
igbinary \
|
||||
ldap \
|
||||
swoole \
|
||||
&& apt-get -y autoremove \
|
||||
&& apt-get clean \
|
||||
&& docker-php-source delete \
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
|
||||
&& rm /var/log/lastlog /var/log/faillog
|
||||
apt-get upgrade -yqq; \
|
||||
apt-get install -yqq --no-install-recommends --show-progress \
|
||||
apt-utils \
|
||||
curl \
|
||||
wget \
|
||||
nano \
|
||||
ncdu \
|
||||
procps \
|
||||
ca-certificates \
|
||||
supervisor \
|
||||
libsodium-dev \
|
||||
# Install PHP extensions (included with dunglas/frankenphp)
|
||||
&& install-php-extensions \
|
||||
bz2 \
|
||||
pcntl \
|
||||
mbstring \
|
||||
bcmath \
|
||||
sockets \
|
||||
pgsql \
|
||||
pdo_pgsql \
|
||||
opcache \
|
||||
exif \
|
||||
pdo_mysql \
|
||||
zip \
|
||||
intl \
|
||||
gd \
|
||||
redis \
|
||||
rdkafka \
|
||||
memcached \
|
||||
igbinary \
|
||||
ldap \
|
||||
&& apt-get -y autoremove \
|
||||
&& apt-get clean \
|
||||
&& docker-php-source delete \
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
|
||||
&& rm /var/log/lastlog /var/log/faillog
|
||||
|
||||
RUN wget -q "https://github.com/aptible/supercronic/releases/download/v0.2.29/supercronic-linux-amd64" \
|
||||
-O /usr/bin/supercronic \
|
||||
&& chmod +x /usr/bin/supercronic \
|
||||
&& mkdir -p /etc/supercronic \
|
||||
&& echo "*/1 * * * * php ${ROOT}/artisan schedule:run --verbose --no-interaction" > /etc/supercronic/laravel
|
||||
RUN arch="$(uname -m)" \
|
||||
&& case "$arch" in \
|
||||
armhf) _cronic_fname='supercronic-linux-arm' ;; \
|
||||
aarch64) _cronic_fname='supercronic-linux-arm64' ;; \
|
||||
x86_64) _cronic_fname='supercronic-linux-amd64' ;; \
|
||||
x86) _cronic_fname='supercronic-linux-386' ;; \
|
||||
*) echo >&2 "error: unsupported architecture: $arch"; exit 1 ;; \
|
||||
esac \
|
||||
&& wget -q "https://github.com/aptible/supercronic/releases/download/v0.2.29/${_cronic_fname}" \
|
||||
-O /usr/bin/supercronic \
|
||||
&& chmod +x /usr/bin/supercronic \
|
||||
&& mkdir -p /etc/supercronic \
|
||||
&& echo "*/1 * * * * php ${ROOT}/artisan schedule:run --no-interaction" > /etc/supercronic/laravel
|
||||
|
||||
RUN userdel --remove --force www-data \
|
||||
&& groupadd --force -g ${WWWGROUP} ${USER} \
|
||||
&& useradd -ms /bin/bash --no-log-init --no-user-group -g ${WWWGROUP} -u ${WWWUSER} ${USER}
|
||||
&& groupadd --force -g ${WWWGROUP} ${USER} \
|
||||
&& useradd -ms /bin/bash --no-log-init --no-user-group -g ${WWWGROUP} -u ${WWWUSER} ${USER}
|
||||
|
||||
RUN chown -R ${USER}:${USER} ${ROOT} /var/{log,run} \
|
||||
&& chmod -R a+rw /var/{log,run}
|
||||
&& chmod -R a+rw ${ROOT} /var/{log,run}
|
||||
|
||||
RUN cp ${PHP_INI_DIR}/php.ini-production ${PHP_INI_DIR}/php.ini
|
||||
|
||||
USER ${USER}
|
||||
|
||||
COPY --chown=${USER}:${USER} --from=vendor /usr/bin/composer /usr/bin/composer
|
||||
#COPY --chown=${USER}:${USER} composer.json composer.lock ./
|
||||
#
|
||||
#RUN composer install \
|
||||
# --no-dev \
|
||||
# --no-interaction \
|
||||
# --no-autoloader \
|
||||
# --no-ansi \
|
||||
# --no-scripts \
|
||||
# --audit
|
||||
|
||||
COPY --chown=${USER}:${USER} . .
|
||||
#COPY --chown=${USER}:${USER} --from=build ${ROOT}/public public
|
||||
|
||||
RUN mkdir -p \
|
||||
storage/framework/{sessions,views,cache,testing} \
|
||||
storage/logs \
|
||||
bootstrap/cache && chmod -R a+rw storage
|
||||
storage/framework/{sessions,views,cache,testing} \
|
||||
storage/logs \
|
||||
bootstrap/cache && chmod -R a+rw storage
|
||||
|
||||
COPY --chown=${USER}:${USER} docker/prod/deployment/supervisord.*.conf /etc/supervisor/conf.d/
|
||||
COPY --chown=${USER}:${USER} docker/prod/deployment/php.ini ${PHP_INI_DIR}/conf.d/99-octane.ini
|
||||
COPY --chown=${USER}:${USER} docker/prod/deployment/start-container /usr/local/bin/start-container
|
||||
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.conf /etc/supervisor/
|
||||
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/octane/FrankenPHP/supervisord.frankenphp.conf /etc/supervisor/conf.d/
|
||||
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.*.conf /etc/supervisor/conf.d/
|
||||
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/start-container /usr/local/bin/start-container
|
||||
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/php.ini ${PHP_INI_DIR}/conf.d/99-octane.ini
|
||||
|
||||
RUN cat .env
|
||||
RUN php artisan env
|
||||
RUN php artisan storage:link
|
||||
# FrankenPHP embedded PHP configuration
|
||||
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/php.ini /lib/php.ini
|
||||
|
||||
#RUN composer install \
|
||||
# --classmap-authoritative \
|
||||
# --no-interaction \
|
||||
# --no-ansi \
|
||||
# --no-dev \
|
||||
# && composer clear-cache
|
||||
|
||||
RUN chmod +x /usr/local/bin/start-container
|
||||
|
||||
RUN cat docker/prod/deployment/utilities.sh >> ~/.bashrc
|
||||
RUN cat ${DOCKER_FILES_BASE_PATH}deployment/utilities.sh >> ~/.bashrc
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
# Laravel Octane Dockerfile
|
||||
<a href="/LICENSE"><img alt="License" src="https://img.shields.io/github/license/exaco/laravel-octane-dockerfile"></a>
|
||||
<a href="https://github.com/exaco/laravel-octane-dockerfile/releases"><img alt="GitHub release (latest by date)" src="https://img.shields.io/github/v/release/exaco/laravel-octane-dockerfile"></a>
|
||||
<a href="https://github.com/exaco/laravel-octane-dockerfile/pulls"><img alt="GitHub closed pull requests" src="https://img.shields.io/github/issues-pr-closed/exaco/laravel-octane-dockerfile"></a>
|
||||
<a href="https://github.com/exaco/laravel-octane-dockerfile/actions/workflows/tests.yml"><img alt="GitHub Workflow Status" src="https://github.com/exaco/laravel-octane-dockerfile/actions/workflows/roadrunner-test.yml/badge.svg"></a>
|
||||
<a href="https://github.com/exaco/laravel-octane-dockerfile/actions/workflows/tests.yml"><img alt="GitHub Workflow Status" src="https://github.com/exaco/laravel-octane-dockerfile/actions/workflows/swoole-test.yml/badge.svg"></a>
|
||||
<a href="https://github.com/exaco/laravel-octane-dockerfile/actions/workflows/tests.yml"><img alt="GitHub Workflow Status" src="https://github.com/exaco/laravel-octane-dockerfile/actions/workflows/frankenphp-test.yml/badge.svg"></a>
|
||||
|
||||
|
||||
Production-ready Dockerfiles for [Laravel Octane](https://github.com/laravel/octane)
|
||||
powered web services and microservices.
|
||||
|
||||
The Docker configuration provides the following setup:
|
||||
|
||||
- PHP 8.2 and 8.3 official Debian-based images
|
||||
- Preconfigured JIT compiler and OPcache
|
||||
|
||||
## Container modes
|
||||
|
||||
You can run the Docker container in different modes:
|
||||
|
||||
| Mode | `CONTAINER_MODE` | HTTP server |
|
||||
| --------------------- | ---------------- | ------------------- |
|
||||
| HTTP Server (default) | `http` | FrankenPHP / Swoole / RoadRunner |
|
||||
| Horizon | `horizon` | - |
|
||||
| Scheduler | `scheduler` | - |
|
||||
| Worker | `worker` | - |
|
||||
|
||||
## Usage
|
||||
|
||||
### Building Docker image
|
||||
1. Clone this repository:
|
||||
```
|
||||
git clone --depth 1 git@github.com:exaco/laravel-octane-dockerfile.git
|
||||
```
|
||||
2. Copy cloned directory content including `deployment` directory, `Dockerfile`, and `.dockerignore` into your Octane powered Laravel project
|
||||
3. Change the directory to your Laravel project
|
||||
4. Build your image:
|
||||
```
|
||||
docker build -t <image-name>:<tag> -f <your-octane-driver>.Dockerfile .
|
||||
```
|
||||
### Running Docker container
|
||||
|
||||
```bash
|
||||
# HTTP mode
|
||||
docker run -p <port>:80 --rm <image-name>:<tag>
|
||||
|
||||
# Horizon mode
|
||||
docker run -e CONTAINER_MODE=horizon --rm <image-name>:<tag>
|
||||
|
||||
# Scheduler mode
|
||||
docker run -e CONTAINER_MODE=scheduler --rm <image-name>:<tag>
|
||||
|
||||
# HTTP mode with Horizon
|
||||
docker run -e WITH_HORIZON=true -p <port>:80 --rm <image-name>:<tag>
|
||||
|
||||
# HTTP mode with Scheduler
|
||||
docker run -e WITH_SCHEDULER=true -p <port>:80 --rm <image-name>:<tag>
|
||||
|
||||
# HTTP mode with Scheduler and Horizon
|
||||
docker run -e WITH_SCHEDULER=true -e WITH_HORIZON=true -p <port>:80 --rm <image-name>:<tag>
|
||||
|
||||
# Worker mode
|
||||
docker run -e CONTAINER_MODE=worker -e WORKER_COMMAND="php /var/www/html/artisan foo:bar" --rm <image-name>:<tag>
|
||||
|
||||
# Running a single command
|
||||
docker run --rm <image-name>:<tag> php artisan about
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Recommended `Swoole` options in `octane.php`
|
||||
|
||||
```php
|
||||
// config/octane.php
|
||||
|
||||
return [
|
||||
'swoole' => [
|
||||
'options' => [
|
||||
'http_compression' => true,
|
||||
'http_compression_level' => 6, // 1 - 9
|
||||
'compression_min_length' => 20,
|
||||
'package_max_length' => 20 * 1024 * 1024, // 20MB
|
||||
'open_http2_protocol' => true,
|
||||
'document_root' => public_path(),
|
||||
'enable_static_handler' => true,
|
||||
]
|
||||
]
|
||||
];
|
||||
```
|
||||
|
||||
## Utilities
|
||||
|
||||
Also, some useful Bash functions and aliases are added in `utilities.sh` that maybe help.
|
||||
|
||||
## Notes
|
||||
|
||||
- Laravel Octane logs request information only in the `local` environment.
|
||||
- Please be aware of `.dockerignore` content
|
||||
|
||||
## ToDo
|
||||
- [x] Add support for PHP 8.3
|
||||
- [x] Add support for worker mode
|
||||
- [ ] Build assets with Bun
|
||||
- [ ] Create standalone and self-executable app
|
||||
- [x] Add support for Horizon
|
||||
- [x] Add support for RoadRunner
|
||||
- [x] Add support for FrankenPHP
|
||||
- [x] Add support for the full-stack apps (Front-end assets)
|
||||
- [ ] Add support `testing` environment and CI
|
||||
- [x] Add support for the Laravel scheduler
|
||||
- [ ] Add support for Laravel Dusk
|
||||
- [x] Support more PHP extensions
|
||||
- [x] Add tests
|
||||
- [ ] Add Alpine-based images
|
||||
|
||||
## Contributing
|
||||
|
||||
Thank you for considering contributing! If you find an issue, or have a better way to do something, feel free to open an
|
||||
issue, or a PR.
|
||||
|
||||
## Credits
|
||||
- [SMortexa](https://github.com/smortexa)
|
||||
- [All contributors](https://github.com/exaco/laravel-octane-dockerfile/graphs/contributors)
|
||||
|
||||
## License
|
||||
|
||||
This repository is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
@@ -0,0 +1,51 @@
|
||||
[program:octane]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=php %(ENV_ROOT)s/artisan octane:start --server=frankenphp --host=0.0.0.0 --port=8000 --admin-port=2019
|
||||
; command=php %(ENV_ROOT)s/artisan octane:start --server=frankenphp --host=localhost --port=443 --admin-port=2019 --https --http-redirect
|
||||
user=%(ENV_USER)s
|
||||
autostart=true
|
||||
autorestart=true
|
||||
environment=LARAVEL_OCTANE="1"
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:horizon]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=php %(ENV_ROOT)s/artisan horizon
|
||||
user=%(ENV_USER)s
|
||||
autostart=%(ENV_WITH_HORIZON)s
|
||||
autorestart=true
|
||||
stdout_logfile=%(ENV_ROOT)s/storage/logs/horizon.log
|
||||
stdout_logfile_maxbytes=200MB
|
||||
stderr_logfile=%(ENV_ROOT)s/storage/logs/horizon.log
|
||||
stderr_logfile_maxbytes=200MB
|
||||
stopwaitsecs=3600
|
||||
|
||||
[program:scheduler]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=supercronic -overlapping /etc/supercronic/laravel
|
||||
user=%(ENV_USER)s
|
||||
autostart=%(ENV_WITH_SCHEDULER)s
|
||||
autorestart=true
|
||||
stdout_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stdout_logfile_maxbytes=200MB
|
||||
stderr_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stderr_logfile_maxbytes=200MB
|
||||
|
||||
[program:clear-scheduler-cache]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=php %(ENV_ROOT)s/artisan schedule:clear-cache
|
||||
user=%(ENV_USER)s
|
||||
autostart=%(ENV_WITH_SCHEDULER)s
|
||||
autorestart=false
|
||||
startsecs=0
|
||||
startretries=1
|
||||
stdout_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stdout_logfile_maxbytes=200MB
|
||||
stderr_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stderr_logfile_maxbytes=200MB
|
||||
|
||||
[include]
|
||||
files=/etc/supervisor/supervisord.conf
|
||||
25
docker/prod/deployment/octane/RoadRunner/.rr.prod.yaml
Normal file
25
docker/prod/deployment/octane/RoadRunner/.rr.prod.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
version: '2.7'
|
||||
rpc:
|
||||
listen: 'tcp://127.0.0.1:6001'
|
||||
server:
|
||||
relay: pipes
|
||||
http:
|
||||
middleware: [ "static", "gzip", "headers" ]
|
||||
max_request_size: 20
|
||||
static:
|
||||
dir: "public"
|
||||
forbid: [ ".php", ".htaccess" ]
|
||||
uploads:
|
||||
forbid: [".php", ".exe", ".bat", ".sh"]
|
||||
pool:
|
||||
allocate_timeout: 10s
|
||||
destroy_timeout: 10s
|
||||
supervisor:
|
||||
max_worker_memory: 128
|
||||
exec_ttl: 60s
|
||||
logs:
|
||||
mode: production
|
||||
level: debug
|
||||
encoding: json
|
||||
status:
|
||||
address: localhost:2114
|
||||
@@ -0,0 +1,50 @@
|
||||
[program:octane]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=php %(ENV_ROOT)s/artisan octane:start --server=roadrunner --host=0.0.0.0 --port=8000 --rpc-port=6001 --rr-config=%(ENV_ROOT)s/.rr.yaml
|
||||
user=%(ENV_USER)s
|
||||
autostart=true
|
||||
autorestart=true
|
||||
environment=LARAVEL_OCTANE="1"
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:horizon]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=php %(ENV_ROOT)s/artisan horizon
|
||||
user=%(ENV_USER)s
|
||||
autostart=%(ENV_WITH_HORIZON)s
|
||||
autorestart=true
|
||||
stdout_logfile=%(ENV_ROOT)s/storage/logs/horizon.log
|
||||
stdout_logfile_maxbytes=200MB
|
||||
stderr_logfile=%(ENV_ROOT)s/storage/logs/horizon.log
|
||||
stderr_logfile_maxbytes=200MB
|
||||
stopwaitsecs=3600
|
||||
|
||||
[program:scheduler]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=supercronic -overlapping /etc/supercronic/laravel
|
||||
user=%(ENV_USER)s
|
||||
autostart=%(ENV_WITH_SCHEDULER)s
|
||||
autorestart=true
|
||||
stdout_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stdout_logfile_maxbytes=200MB
|
||||
stderr_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stderr_logfile_maxbytes=200MB
|
||||
|
||||
[program:clear-scheduler-cache]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=php %(ENV_ROOT)s/artisan schedule:clear-cache
|
||||
user=%(ENV_USER)s
|
||||
autostart=%(ENV_WITH_SCHEDULER)s
|
||||
autorestart=false
|
||||
startsecs=0
|
||||
startretries=1
|
||||
stdout_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stdout_logfile_maxbytes=200MB
|
||||
stderr_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stderr_logfile_maxbytes=200MB
|
||||
|
||||
[include]
|
||||
files=/etc/supervisor/supervisord.conf
|
||||
50
docker/prod/deployment/octane/Swoole/supervisord.swoole.conf
Normal file
50
docker/prod/deployment/octane/Swoole/supervisord.swoole.conf
Normal file
@@ -0,0 +1,50 @@
|
||||
[program:octane]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=php %(ENV_ROOT)s/artisan octane:start --server=swoole --host=0.0.0.0 --port=8000
|
||||
user=%(ENV_USER)s
|
||||
autostart=true
|
||||
autorestart=true
|
||||
environment=LARAVEL_OCTANE="1"
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:horizon]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=php %(ENV_ROOT)s/artisan horizon
|
||||
user=%(ENV_USER)s
|
||||
autostart=%(ENV_WITH_HORIZON)s
|
||||
autorestart=true
|
||||
stdout_logfile=%(ENV_ROOT)s/storage/logs/horizon.log
|
||||
stdout_logfile_maxbytes=200MB
|
||||
stderr_logfile=%(ENV_ROOT)s/storage/logs/horizon.log
|
||||
stderr_logfile_maxbytes=200MB
|
||||
stopwaitsecs=3600
|
||||
|
||||
[program:scheduler]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=supercronic -overlapping /etc/supercronic/laravel
|
||||
user=%(ENV_USER)s
|
||||
autostart=%(ENV_WITH_SCHEDULER)s
|
||||
autorestart=true
|
||||
stdout_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stdout_logfile_maxbytes=200MB
|
||||
stderr_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stderr_logfile_maxbytes=200MB
|
||||
|
||||
[program:clear-scheduler-cache]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=php %(ENV_ROOT)s/artisan schedule:clear-cache
|
||||
user=%(ENV_USER)s
|
||||
autostart=%(ENV_WITH_SCHEDULER)s
|
||||
autorestart=false
|
||||
startsecs=0
|
||||
startretries=1
|
||||
stdout_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stdout_logfile_maxbytes=200MB
|
||||
stderr_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
stderr_logfile_maxbytes=200MB
|
||||
|
||||
[include]
|
||||
files=/etc/supervisor/supervisord.conf
|
||||
@@ -4,6 +4,7 @@ upload_max_filesize = 100M
|
||||
expose_php = 0
|
||||
realpath_cache_size = 16M
|
||||
realpath_cache_ttl = 360
|
||||
max_input_time = 5
|
||||
|
||||
[Opcache]
|
||||
opcache.enable = 1
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
#!/usr/bin/env sh
|
||||
set -e
|
||||
|
||||
container_mode=${CONTAINER_MODE:-http}
|
||||
container_mode=${CONTAINER_MODE:-"http"}
|
||||
octane_server=${OCTANE_SERVER}
|
||||
auto_db_migrate=${AUTO_DB_MIGRATE:-false}
|
||||
echo "Container mode: $container_mode"
|
||||
@@ -9,7 +9,7 @@ echo "Container mode: $container_mode"
|
||||
initialStuff() {
|
||||
if [ ${auto_db_migrate} = "true" ]; then
|
||||
echo "Auto database migration enabled."
|
||||
php artisan migrate --force
|
||||
php artisan migrate --isolated --force
|
||||
fi
|
||||
php artisan optimize:clear; \
|
||||
php artisan event:cache; \
|
||||
@@ -22,7 +22,16 @@ if [ "$1" != "" ]; then
|
||||
elif [ ${container_mode} = "http" ]; then
|
||||
echo "Octane Server: $octane_server"
|
||||
initialStuff
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.swoole.conf
|
||||
if [ ${octane_server} = "frankenphp" ]; then
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.frankenphp.conf
|
||||
elif [ ${octane_server} = "swoole" ]; then
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.swoole.conf
|
||||
elif [ ${octane_server} = "roadrunner" ]; then
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.roadrunner.conf
|
||||
else
|
||||
echo "Invalid Octane server supplied."
|
||||
exit 1
|
||||
fi
|
||||
elif [ ${container_mode} = "horizon" ]; then
|
||||
initialStuff
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.horizon.conf
|
||||
|
||||
14
docker/prod/deployment/supervisord.conf
Normal file
14
docker/prod/deployment/supervisord.conf
Normal file
@@ -0,0 +1,14 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
user=%(ENV_USER)s
|
||||
logfile=/var/log/supervisor/supervisord.log
|
||||
pidfile=/var/run/supervisord.pid
|
||||
|
||||
[unix_http_server]
|
||||
file=/var/run/supervisor.sock
|
||||
|
||||
[supervisorctl]
|
||||
serverurl=unix:///var/run/supervisor.sock
|
||||
|
||||
[rpcinterface:supervisor]
|
||||
supervisor.rpcinterface_factory=supervisor.rpcinterface:make_main_rpcinterface
|
||||
@@ -1,9 +1,3 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
user=%(ENV_USER)s
|
||||
logfile=/var/log/supervisor/supervisord.log
|
||||
pidfile=/var/run/supervisord.pid
|
||||
|
||||
[program:horizon]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=php %(ENV_ROOT)s/artisan horizon
|
||||
@@ -15,3 +9,6 @@ stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
stopwaitsecs=3600
|
||||
|
||||
[include]
|
||||
files=/etc/supervisor/supervisord.conf
|
||||
@@ -1,9 +1,3 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
user=%(ENV_USER)s
|
||||
logfile=/var/log/supervisor/supervisord.log
|
||||
pidfile=/var/run/supervisord.pid
|
||||
|
||||
[program:scheduler]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=supercronic -overlapping /etc/supercronic/laravel
|
||||
@@ -21,7 +15,12 @@ command=php %(ENV_ROOT)s/artisan schedule:clear-cache
|
||||
user=%(ENV_USER)s
|
||||
autostart=true
|
||||
autorestart=false
|
||||
startsecs=0
|
||||
startretries=1
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[include]
|
||||
files=/etc/supervisor/supervisord.conf
|
||||
@@ -1,9 +1,3 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
user=%(ENV_USER)s
|
||||
logfile=/var/log/supervisor/supervisord.log
|
||||
pidfile=/var/run/supervisord.pid
|
||||
|
||||
[program:worker]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=%(ENV_WORKER_COMMAND)s
|
||||
@@ -14,3 +8,6 @@ stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[include]
|
||||
files=/etc/supervisor/supervisord.conf
|
||||
@@ -22,11 +22,12 @@ test('test that new manager can be invited', async ({ page }) => {
|
||||
const editorId = Math.round(Math.random() * 10000);
|
||||
await page.getByLabel('Email').fill(`new+${editorId}@editor.test`);
|
||||
await page.getByRole('button', { name: 'Manager' }).click();
|
||||
await page.getByRole('button', { name: 'Add', exact: true }).click();
|
||||
await page.reload();
|
||||
await expect(page.getByRole('main')).toContainText(
|
||||
`new+${editorId}@editor.test`
|
||||
);
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Add', exact: true }).click(),
|
||||
expect(page.getByRole('main')).toContainText(
|
||||
`new+${editorId}@editor.test`
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
test('test that new employee can be invited', async ({ page }) => {
|
||||
@@ -34,11 +35,12 @@ test('test that new employee can be invited', async ({ page }) => {
|
||||
const editorId = Math.round(Math.random() * 10000);
|
||||
await page.getByLabel('Email').fill(`new+${editorId}@editor.test`);
|
||||
await page.getByRole('button', { name: 'Employee' }).click();
|
||||
await page.getByRole('button', { name: 'Add', exact: true }).click();
|
||||
await page.reload();
|
||||
await expect(page.getByRole('main')).toContainText(
|
||||
`new+${editorId}@editor.test`
|
||||
);
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Add', exact: true }).click(),
|
||||
await expect(page.getByRole('main')).toContainText(
|
||||
`new+${editorId}@editor.test`
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
test('test that new admin can be invited', async ({ page }) => {
|
||||
@@ -46,21 +48,24 @@ test('test that new admin can be invited', async ({ page }) => {
|
||||
const adminId = Math.round(Math.random() * 10000);
|
||||
await page.getByLabel('Email').fill(`new+${adminId}@admin.test`);
|
||||
await page.getByRole('button', { name: 'Administrator' }).click();
|
||||
await page.getByRole('button', { name: 'Add', exact: true }).click();
|
||||
await page.reload();
|
||||
await expect(page.getByRole('main')).toContainText(
|
||||
`new+${adminId}@admin.test`
|
||||
);
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Add', exact: true }).click(),
|
||||
expect(page.getByRole('main')).toContainText(
|
||||
`new+${adminId}@admin.test`
|
||||
),
|
||||
]);
|
||||
});
|
||||
test('test that error shows if no role is selected', async ({ page }) => {
|
||||
await goToOrganizationSettings(page);
|
||||
const noRoleId = Math.round(Math.random() * 10000);
|
||||
|
||||
await page.getByLabel('Email').fill(`new+${noRoleId}@norole.test`);
|
||||
await page.getByRole('button', { name: 'Add', exact: true }).click();
|
||||
await expect(page.getByRole('main')).toContainText(
|
||||
'The role field is required.'
|
||||
);
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Add', exact: true }).click(),
|
||||
expect(page.getByRole('main')).toContainText(
|
||||
'The role field is required.'
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
// TODO: Add Test for import
|
||||
|
||||
1428
package-lock.json
generated
1428
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -44,10 +44,10 @@ export default defineConfig({
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
// {
|
||||
// name: 'webkit',
|
||||
// use: { ...devices['Desktop Safari'] },
|
||||
// },
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ref, watch } from 'vue';
|
||||
import { getDayJsInstance, getLocalizedDayJs } from '@/utils/time';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
@@ -14,11 +14,15 @@ const model = defineModel<string | null>({
|
||||
|
||||
const tempDate = ref(getLocalizedDayJs(model.value).format('YYYY-MM-DD'));
|
||||
|
||||
watch(model, (value) => {
|
||||
tempDate.value = getLocalizedDayJs(value).format('YYYY-MM-DD');
|
||||
});
|
||||
|
||||
function updateDate(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const newValue = target.value;
|
||||
const newDate = getDayJsInstance()(newValue);
|
||||
if (newDate) {
|
||||
if (newDate.isValid()) {
|
||||
model.value = getLocalizedDayJs(model.value)
|
||||
.set('year', newDate.year())
|
||||
.set('month', newDate.month())
|
||||
|
||||
@@ -46,6 +46,7 @@ const billableOptionInfoTexts: { [key in BillableKey]: string } = {
|
||||
const billableOptionInfoText = computed(() => {
|
||||
return billableOptionInfoTexts[billableRateSelect.value];
|
||||
});
|
||||
const emit = defineEmits(['submit']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -60,7 +61,10 @@ const billableOptionInfoText = computed(() => {
|
||||
class="sm:max-w-[120px]"
|
||||
v-if="billableRateSelect === 'custom-rate'">
|
||||
<InputLabel for="billableRate" value="Billable Rate" />
|
||||
<BillableRateInput v-model="billableRate" name="billableRate" />
|
||||
<BillableRateInput
|
||||
@keydown.enter="emit('submit')"
|
||||
v-model="billableRate"
|
||||
name="billableRate" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center text-muted pt-2 pl-1">
|
||||
|
||||
@@ -8,12 +8,13 @@ import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import { useProjectsStore } from '@/utils/useProjects';
|
||||
import { useFocus } from '@vueuse/core';
|
||||
import ClientDropdown from '@/Components/Common/Client/ClientDropdown.vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import Badge from '@/Components/Common/Badge.vue';
|
||||
import { useClientsStore } from '@/utils/useClients';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import ProjectColorSelector from '@/Components/Common/Project/ProjectColorSelector.vue';
|
||||
import ProjectEditBillableSection from '@/Components/Common/Project/ProjectEditBillableSection.vue';
|
||||
import { UserCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import InputLabel from '@/Components/InputLabel.vue';
|
||||
|
||||
const { updateProject } = useProjectsStore();
|
||||
const { clients } = storeToRefs(useClientsStore());
|
||||
@@ -63,10 +64,14 @@ const currentClientName = computed(() => {
|
||||
<div
|
||||
class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-4">
|
||||
<div class="flex-1 flex items-center">
|
||||
<div class="px-3">
|
||||
<div class="text-center pr-5">
|
||||
<InputLabel for="color" value="Color" />
|
||||
<ProjectColorSelector
|
||||
v-model="project.color"></ProjectColorSelector>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<InputLabel for="projectName" value="Project name" />
|
||||
<TextInput
|
||||
id="projectName"
|
||||
ref="projectNameInput"
|
||||
@@ -79,22 +84,26 @@ const currentClientName = computed(() => {
|
||||
autocomplete="projectName" />
|
||||
</div>
|
||||
<div class="">
|
||||
<ClientDropdown v-model="project.client_id">
|
||||
<InputLabel for="client" value="Client" />
|
||||
<ClientDropdown class="mt-2" v-model="project.client_id">
|
||||
<template #trigger>
|
||||
<Badge size="large">
|
||||
<div
|
||||
:class="
|
||||
twMerge('inline-block rounded-full')
|
||||
"></div>
|
||||
<span>
|
||||
{{ currentClientName }}
|
||||
</span>
|
||||
<Badge
|
||||
class="bg-input-background cursor-pointer hover:bg-tertiary"
|
||||
size="xlarge">
|
||||
<div class="flex items-center space-x-2">
|
||||
<UserCircleIcon
|
||||
class="w-5 text-icon-default"></UserCircleIcon>
|
||||
<span>
|
||||
{{ currentClientName }}
|
||||
</span>
|
||||
</div>
|
||||
</Badge>
|
||||
</template>
|
||||
</ClientDropdown>
|
||||
</div>
|
||||
</div>
|
||||
<ProjectEditBillableSection
|
||||
@submit="submit"
|
||||
v-model:isBillable="project.is_billable"
|
||||
v-model:billableRate="
|
||||
project.billable_rate
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
import TextInput from '@/Components/TextInput.vue';
|
||||
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
||||
import DialogModal from '@/Components/DialogModal.vue';
|
||||
import { ref, watch } from 'vue';
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import { useFocus } from '@vueuse/core';
|
||||
import TimeTrackerTagDropdown from '@/Components/Common/TimeTracker/TimeTrackerTagDropdown.vue';
|
||||
import TimeTrackerProjectTaskDropdown from '@/Components/Common/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
||||
import BillableToggleButton from '@/Components/Common/BillableToggleButton.vue';
|
||||
@@ -19,6 +18,16 @@ const { createTimeEntry } = useTimeEntriesStore();
|
||||
const show = defineModel('show', { default: false });
|
||||
const saving = ref(false);
|
||||
|
||||
const description = ref<HTMLInputElement | null>(null);
|
||||
|
||||
watch(show, (value) => {
|
||||
if (value) {
|
||||
nextTick(() => {
|
||||
description.value?.focus();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const timeEntryDefaultValues = {
|
||||
description: '',
|
||||
project_id: null,
|
||||
@@ -36,12 +45,15 @@ const localStart = ref(
|
||||
getLocalizedDayJs(timeEntryDefaultValues.start).format()
|
||||
);
|
||||
|
||||
const localEnd = ref(getLocalizedDayJs(timeEntryDefaultValues.end).format());
|
||||
|
||||
watch(localStart, (value) => {
|
||||
timeEntry.value.start = getLocalizedDayJs(value).utc().format();
|
||||
if (getLocalizedDayJs(localEnd.value).isBefore(getLocalizedDayJs(value))) {
|
||||
localEnd.value = value;
|
||||
}
|
||||
});
|
||||
|
||||
const localEnd = ref(getLocalizedDayJs(timeEntryDefaultValues.end).format());
|
||||
|
||||
watch(localEnd, (value) => {
|
||||
timeEntry.value.end = getLocalizedDayJs(value).utc().format();
|
||||
});
|
||||
@@ -53,10 +65,6 @@ async function submit() {
|
||||
localEnd.value = getLocalizedDayJs(timeEntryDefaultValues.end).format();
|
||||
show.value = false;
|
||||
}
|
||||
|
||||
const projectNameInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
useFocus(projectNameInput, { initialValue: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -73,11 +81,11 @@ useFocus(projectNameInput, { initialValue: true });
|
||||
<InputLabel for="description" value="Description" />
|
||||
<TextInput
|
||||
id="description"
|
||||
ref="description"
|
||||
v-model="timeEntry.description"
|
||||
@keydown.enter="submit"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
autofocus />
|
||||
class="mt-1 block w-full" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
|
||||
@@ -33,6 +33,7 @@ const open = ref(false);
|
||||
@changed="
|
||||
(newStart, newEnd) => emit('changed', newStart, newEnd)
|
||||
"
|
||||
focus
|
||||
:start="start"
|
||||
:end="end">
|
||||
</TimeRangeSelector>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { ref, watch } from 'vue';
|
||||
import { getDayJsInstance, getLocalizedDayJs } from '@/utils/time';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { useFocus } from '@vueuse/core';
|
||||
|
||||
// This has to be a localized timestamp, not UTC
|
||||
const model = defineModel<string | null>({
|
||||
@@ -11,19 +12,31 @@ const model = defineModel<string | null>({
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
size: 'base' | 'large';
|
||||
focus: boolean;
|
||||
}>(),
|
||||
{
|
||||
size: 'base',
|
||||
focus: false,
|
||||
}
|
||||
);
|
||||
|
||||
const hours = computed(() => {
|
||||
return model.value ? getLocalizedDayJs(model.value).format('HH') : null;
|
||||
});
|
||||
|
||||
const minutes = computed(() => {
|
||||
return model.value ? getLocalizedDayJs(model.value).format('mm') : null;
|
||||
});
|
||||
const hours = ref(
|
||||
model.value ? getLocalizedDayJs(model.value).format('HH') : null
|
||||
);
|
||||
const minutes = ref(
|
||||
model.value ? getLocalizedDayJs(model.value).format('mm') : null
|
||||
);
|
||||
watch(
|
||||
() => model.value,
|
||||
() => {
|
||||
hours.value = model.value
|
||||
? getLocalizedDayJs(model.value).format('HH')
|
||||
: null;
|
||||
minutes.value = model.value
|
||||
? getLocalizedDayJs(model.value).format('mm')
|
||||
: null;
|
||||
}
|
||||
);
|
||||
|
||||
function updateMinutes(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
@@ -33,19 +46,31 @@ function updateMinutes(event: Event) {
|
||||
.set('minutes', Math.min(parseInt(newValue), 59))
|
||||
.format();
|
||||
}
|
||||
minutes.value = model.value
|
||||
? getLocalizedDayJs(model.value).format('mm')
|
||||
: null;
|
||||
}
|
||||
|
||||
function updateHours(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const newValue = target.value;
|
||||
if (!isNaN(parseInt(newValue))) {
|
||||
if (newValue.endsWith(':')) {
|
||||
minutesInput.value?.focus();
|
||||
} else if (!isNaN(parseInt(newValue))) {
|
||||
model.value = getLocalizedDayJs(model.value)
|
||||
.set('hours', Math.min(parseInt(newValue), 23))
|
||||
.format();
|
||||
}
|
||||
hours.value = model.value
|
||||
? getLocalizedDayJs(model.value).format('HH')
|
||||
: null;
|
||||
}
|
||||
|
||||
const hoursInput = ref<HTMLInputElement | null>(null);
|
||||
const minutesInput = ref<HTMLInputElement | null>(null);
|
||||
const emit = defineEmits(['changed']);
|
||||
|
||||
useFocus(hoursInput, { initialValue: props.focus });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -58,7 +83,8 @@ const emit = defineEmits(['changed']);
|
||||
)
|
||||
">
|
||||
<input
|
||||
:value="hours"
|
||||
v-model="hours"
|
||||
ref="hoursInput"
|
||||
@input="updateHours"
|
||||
@keydown.enter="emit('changed')"
|
||||
@focus="($event.target as HTMLInputElement).select()"
|
||||
@@ -72,7 +98,8 @@ const emit = defineEmits(['changed']);
|
||||
" />
|
||||
<span>:</span>
|
||||
<input
|
||||
:value="minutes"
|
||||
v-model="minutes"
|
||||
ref="minutesInput"
|
||||
@keydown.enter="emit('changed')"
|
||||
@input="updateMinutes"
|
||||
@focus="($event.target as HTMLInputElement).select()"
|
||||
|
||||
@@ -9,6 +9,7 @@ import dayjs from 'dayjs';
|
||||
const props = defineProps<{
|
||||
start: string;
|
||||
end: string | null;
|
||||
focus?: boolean;
|
||||
}>();
|
||||
|
||||
// The timestamps for the changed event are UTC
|
||||
@@ -24,7 +25,11 @@ watch(props, () => {
|
||||
tempEnd.value = getLocalizedDayJs(props.end).format();
|
||||
});
|
||||
function updateTimeEntry() {
|
||||
if (tempStart.value !== props.start || tempEnd.value !== props.end) {
|
||||
const tempStartUtc = getDayJsInstance()(tempStart.value).utc().format();
|
||||
const tempEndUtc = tempEnd.value
|
||||
? getDayJsInstance()(tempEnd.value).utc().format()
|
||||
: null;
|
||||
if (tempStartUtc !== props.start || tempEndUtc !== props.end) {
|
||||
emit(
|
||||
'changed',
|
||||
getDayJsInstance()(tempStart.value).utc().format(),
|
||||
@@ -52,6 +57,7 @@ watch(focused, (newValue, oldValue) => {
|
||||
<div class="space-y-1">
|
||||
<TimePicker
|
||||
data-testid="time_entry_range_start"
|
||||
:focus
|
||||
@changed="updateTimeEntry"
|
||||
v-model="tempStart"></TimePicker>
|
||||
<DatePicker
|
||||
|
||||
@@ -314,7 +314,7 @@ const showCreateProject = ref(false);
|
||||
<span>Add new project</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<Dropdown v-else v-model="open" :closeOnContentClick="true" align="bottom">
|
||||
<Dropdown v-else v-model="open" :closeOnContentClick="false" align="bottom">
|
||||
<template #trigger>
|
||||
<ProjectBadge
|
||||
ref="projectDropdownTrigger"
|
||||
@@ -340,6 +340,7 @@ const showCreateProject = ref(false);
|
||||
:value="searchValue"
|
||||
@input="updateSearchValue"
|
||||
@keydown.enter="addClientIfNoneExists"
|
||||
@click.prevent="searchInput?.focus()"
|
||||
data-testid="client_dropdown_search"
|
||||
@keydown.up.prevent="moveHighlightUp"
|
||||
@keydown.down.prevent="moveHighlightDown"
|
||||
|
||||
@@ -119,6 +119,13 @@ async function updateTimeRange(newStart: string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const startTime = computed(() => {
|
||||
if (currentTimeEntry.value.start && currentTimeEntry.value.start !== '') {
|
||||
return currentTimeEntry.value.start;
|
||||
}
|
||||
return dayjs().utc().format();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -142,7 +149,7 @@ async function updateTimeRange(newStart: string) {
|
||||
<template #content>
|
||||
<TimeRangeSelector
|
||||
@changed="updateTimeRange"
|
||||
:start="currentTimeEntry.start"
|
||||
:start="startTime"
|
||||
:end="null">
|
||||
</TimeRangeSelector>
|
||||
</template>
|
||||
|
||||
@@ -8,7 +8,7 @@ import TimeTrackerStartStop from '@/Components/Common/TimeTrackerStartStop.vue';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
const store = useCurrentTimeEntryStore();
|
||||
const { currentTimeEntry, now, isActive } = storeToRefs(store);
|
||||
const { onToggleButtonPress } = store;
|
||||
const { setActiveState } = store;
|
||||
|
||||
const currentTime = computed(() => {
|
||||
if (now.value && currentTimeEntry.value.start) {
|
||||
@@ -50,7 +50,7 @@ const isRunningInDifferentOrganization = computed(() => {
|
||||
</div>
|
||||
<TimeTrackerStartStop
|
||||
:active="isActive"
|
||||
@changed="onToggleButtonPress"
|
||||
@changed="setActiveState"
|
||||
size="base"></TimeTrackerStartStop>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -111,6 +111,7 @@ const option = ref({
|
||||
<div class="px-2">
|
||||
<v-chart
|
||||
class="chart"
|
||||
:autoresize="true"
|
||||
:option="option"
|
||||
style="height: 260px; background-color: transparent" />
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import BillableToggleButton from '@/Components/Common/BillableToggleButton.vue';
|
||||
import TimeTrackerStartStop from '@/Components/Common/TimeTrackerStartStop.vue';
|
||||
import { usePage } from '@inertiajs/vue3';
|
||||
import { type User } from '@/types/models';
|
||||
import { computed, onMounted, watch } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
@@ -31,8 +31,8 @@ dayjs.extend(utc);
|
||||
|
||||
const currentTimeEntryStore = useCurrentTimeEntryStore();
|
||||
const { currentTimeEntry, isActive, now } = storeToRefs(currentTimeEntryStore);
|
||||
const { startLiveTimer, stopLiveTimer, onToggleButtonPress } =
|
||||
currentTimeEntryStore;
|
||||
const { startLiveTimer, stopLiveTimer, setActiveState } = currentTimeEntryStore;
|
||||
const currentTimeEntryDescriptionInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
watch(isActive, () => {
|
||||
if (isActive.value) {
|
||||
@@ -71,9 +71,16 @@ function updateTimeEntry() {
|
||||
}
|
||||
}
|
||||
|
||||
function onToggleButtonPress(newState: boolean) {
|
||||
setActiveState(newState);
|
||||
if (newState) {
|
||||
currentTimeEntryDescriptionInput.value?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function startTimerIfNotActive() {
|
||||
if (!isActive.value) {
|
||||
onToggleButtonPress(true);
|
||||
setActiveState(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +123,7 @@ function switchToTimeEntryOrganization() {
|
||||
<input
|
||||
placeholder="What are you working on?"
|
||||
data-testid="time_entry_description"
|
||||
ref="currentTimeEntryDescriptionInput"
|
||||
v-model="currentTimeEntry.description"
|
||||
@keydown.enter="startTimerIfNotActive"
|
||||
@blur="updateTimeEntry"
|
||||
@@ -144,7 +152,10 @@ function switchToTimeEntryOrganization() {
|
||||
"></BillableToggleButton>
|
||||
</div>
|
||||
<div class="border-l border-card-border">
|
||||
<TimeTrackerRangeSelector></TimeTrackerRangeSelector>
|
||||
<TimeTrackerRangeSelector
|
||||
@keydown.enter="
|
||||
startTimerIfNotActive
|
||||
"></TimeTrackerRangeSelector>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,24 +4,24 @@ import Banner from '@/Components/Banner.vue';
|
||||
import OrganizationSwitcher from '@/Components/OrganizationSwitcher.vue';
|
||||
import CurrentSidebarTimer from '@/Components/CurrentSidebarTimer.vue';
|
||||
import {
|
||||
Bars3Icon,
|
||||
ChartBarIcon,
|
||||
ClockIcon,
|
||||
Cog6ToothIcon,
|
||||
CreditCardIcon,
|
||||
FolderIcon,
|
||||
HomeIcon,
|
||||
TagIcon,
|
||||
UserCircleIcon,
|
||||
UserGroupIcon,
|
||||
Bars3Icon,
|
||||
XMarkIcon,
|
||||
CreditCardIcon,
|
||||
} from '@heroicons/vue/20/solid';
|
||||
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
|
||||
import UserSettingsIcon from '@/Components/UserSettingsIcon.vue';
|
||||
import MainContainer from '@/Pages/MainContainer.vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import NotificationContainer from '@/Components/NotificationContainer.vue';
|
||||
import { initializeStores } from '@/utils/init';
|
||||
import { initializeStores, refreshStores } from '@/utils/init';
|
||||
import {
|
||||
canUpdateOrganization,
|
||||
canViewClients,
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { isBillingActivated } from '@/utils/billing';
|
||||
import type { User } from '@/types/models';
|
||||
import { ArrowsRightLeftIcon } from '@heroicons/vue/16/solid';
|
||||
import { fetchToken, isTokenValid } from '@/utils/api';
|
||||
|
||||
defineProps({
|
||||
title: String,
|
||||
@@ -45,6 +46,12 @@ onMounted(async () => {
|
||||
window.initialDataLoaded = true;
|
||||
initializeStores();
|
||||
}
|
||||
window.onfocus = async () => {
|
||||
if (!isTokenValid()) {
|
||||
await fetchToken();
|
||||
}
|
||||
refreshStores();
|
||||
};
|
||||
});
|
||||
|
||||
const page = usePage<{
|
||||
|
||||
@@ -49,12 +49,6 @@ const verificationLinkSent = computed(
|
||||
</PrimaryButton>
|
||||
|
||||
<div>
|
||||
<Link
|
||||
:href="route('profile.show')"
|
||||
class="underline text-sm text-muted hover:text-white rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
Edit Profile</Link
|
||||
>
|
||||
|
||||
<Link
|
||||
:href="route('logout')"
|
||||
method="post"
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
ZodiosQueryParamsByAlias,
|
||||
} from '@zodios/core';
|
||||
import { api } from '../../../openapi.json.client';
|
||||
import { router } from '@inertiajs/vue3';
|
||||
|
||||
export type SolidTimeApi = ApiOf<typeof api>;
|
||||
|
||||
@@ -123,3 +124,16 @@ export type UpdateOrganizationBody = ZodiosBodyByAlias<
|
||||
SolidTimeApi,
|
||||
'updateOrganization'
|
||||
>;
|
||||
|
||||
export async function fetchToken() {
|
||||
return new Promise((resolve) => {
|
||||
router.reload({
|
||||
onFinish: () => {
|
||||
resolve(null);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
export function isTokenValid() {
|
||||
return window.document.cookie.includes('solidtime_session');
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@ import { useTimeEntriesStore } from '@/utils/useTimeEntries';
|
||||
import { canViewClients, canViewMembers } from '@/utils/permissions';
|
||||
|
||||
export function initializeStores() {
|
||||
refreshStores();
|
||||
useTimeEntriesStore().fetchTimeEntries();
|
||||
}
|
||||
|
||||
export function refreshStores() {
|
||||
useProjectsStore().fetchProjects();
|
||||
useTasksStore().fetchTasks();
|
||||
useTagsStore().fetchTags();
|
||||
@@ -18,5 +23,4 @@ export function initializeStores() {
|
||||
if (canViewClients()) {
|
||||
useClientsStore().fetchClients();
|
||||
}
|
||||
useTimeEntriesStore().fetchTimeEntries();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { fetchToken } from '@/utils/api';
|
||||
|
||||
export type NotificationType = 'success' | 'error';
|
||||
|
||||
@@ -37,16 +38,6 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchToken() {
|
||||
return new Promise((resolve) => {
|
||||
router.reload({
|
||||
onFinish: () => {
|
||||
resolve(null);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function handleApiRequestNotifications<T>(
|
||||
apiRequest: () => Promise<T>,
|
||||
successMessage?: string,
|
||||
|
||||
@@ -191,7 +191,7 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
||||
return false;
|
||||
});
|
||||
|
||||
async function onToggleButtonPress(newState: boolean) {
|
||||
async function setActiveState(newState: boolean) {
|
||||
if (newState) {
|
||||
startLiveTimer();
|
||||
await startTimer();
|
||||
@@ -214,6 +214,6 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
||||
startLiveTimer,
|
||||
stopLiveTimer,
|
||||
now,
|
||||
onToggleButtonPress,
|
||||
setActiveState,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -78,7 +78,7 @@ export const useTimeEntriesStore = defineStore('timeEntries', () => {
|
||||
async function updateTimeEntry(timeEntry: TimeEntry) {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
if (organizationId) {
|
||||
await handleApiRequestNotifications(
|
||||
const response = await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.updateTimeEntry(timeEntry, {
|
||||
params: {
|
||||
@@ -89,6 +89,9 @@ export const useTimeEntriesStore = defineStore('timeEntries', () => {
|
||||
'Time entry updated successfully',
|
||||
'Failed to update time entry'
|
||||
);
|
||||
timeEntries.value = timeEntries.value.map((entry) =>
|
||||
entry.id === timeEntry.id ? response.data : entry
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,23 +5,15 @@ declare(strict_types=1);
|
||||
namespace Tests\Unit\Database;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MigrationTest extends \Tests\TestCase
|
||||
class MigrationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_fresh_migration_and_rollback_runs_successfully(): void
|
||||
{
|
||||
Artisan::call('migrate:fresh');
|
||||
Artisan::call('migrate:rollback');
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function testFreshMigrationWithSeederAndRollbackRunsSuccessfully(): void
|
||||
{
|
||||
Artisan::call('migrate:fresh --seed');
|
||||
Artisan::call('migrate:rollback');
|
||||
$this->assertTrue(true);
|
||||
$this->artisan('migrate:rollback')
|
||||
->assertSuccessful();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,23 +5,25 @@ declare(strict_types=1);
|
||||
namespace Tests\Unit\Database;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SeederTest extends \Tests\TestCase
|
||||
class SeederTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_running_the_seeder_multiple_times_runs_successfully(): void
|
||||
{
|
||||
Artisan::call('db:seed');
|
||||
Artisan::call('db:seed');
|
||||
$this->assertTrue(true);
|
||||
$this->artisan('db:seed')
|
||||
->assertSuccessful();
|
||||
$this->artisan('db:seed')
|
||||
->assertSuccessful();
|
||||
}
|
||||
|
||||
public function test_fresh_migration_with_seeder_and_rollback_runs_successfully(): void
|
||||
{
|
||||
Artisan::call('migrate:fresh --seed');
|
||||
Artisan::call('migrate:rollback');
|
||||
$this->assertTrue(true);
|
||||
$this->artisan('db:seed')
|
||||
->assertSuccessful();
|
||||
$this->artisan('migrate:rollback')
|
||||
->assertSuccessful();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user