Published: Jan 23, 2025 by Isaac Johnson
Today we’ll dig into two very good containerized Open-Source time tracking apps. The first, EigenFocus I found by way of a Marius article and is a very clean and simple app a person could use to track time spent on various activities.
The second, a bit more full featured, in Kimai which is fully open-source but also has a paid cloud hosted version for those that don’t want to self host (at 3-4 Euro/month, it’s pretty reasonable).
EigenFocus
We can launch Eigenfocus using Docker by way of the steps listed in Github
docker run \
--restart unless-stopped \
-v ./app-data:/eigenfocus-app/app-data \
-p 3001:3000 \
-e DEFAULT_HOST_URL=http://localhost:3001 \
-d \
eigenfocus/eigenfocus:0.6.0
Let’s convert that over to a Kubernetes Manifest
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: eigenfocus-app-data-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: eigenfocus-deployment
spec:
replicas: 1
selector:
matchLabels:
app: eigenfocus
template:
metadata:
labels:
app: eigenfocus
spec:
containers:
- name: eigenfocus
image: eigenfocus/eigenfocus:0.6.0
ports:
- containerPort: 3000
env:
- name: DEFAULT_HOST_URL
value: "http://localhost:3000"
volumeMounts:
- name: app-data-volume
mountPath: /eigenfocus-app/app-data
volumes:
- name: app-data-volume
persistentVolumeClaim:
claimName: eigenfocus-app-data-pvc
---
apiVersion: v1
kind: Service
metadata:
name: eigenfocus-service
spec:
selector:
app: eigenfocus
ports:
- protocol: TCP
port: 80
targetPort: 3000
type: ClusterIP
I can now apply it
$ kubectl apply -f ./eigenfocus.yaml
persistentvolumeclaim/eigenfocus-app-data-pvc created
deployment.apps/eigenfocus-deployment created
service/eigenfocus-service created
Once we see the pod is up
$ kubectl get deployment eigenfocus-deployment
NAME READY UP-TO-DATE AVAILABLE AGE
eigenfocus-deployment 1/1 1 1 66s
Let’s do a port forward test
$ kubectl get deployment eigenfocus-deployment
NAME READY UP-TO-DATE AVAILABLE AGE
eigenfocus-deployment 1/1 1 1 66s
$ kubectl get pods | grep eigen
eigenfocus-deployment-8699f9f986-lm8n5 1/1 Running 0 98s
$ kubectl port-forward eigenfocus-deployment-8699f9f986-lm8n5 3000:3000
Forwarding from 127.0.0.1:3000 -> 3000
Forwarding from [::1]:3000 -> 3000
My first step is to set a timezone and language
I’m then prompted with the getting started page
Let’s start by creating a new project
I’ll call it “FreshBrewed”
I can now see the new project
I went to the Board view and added a column
I can enter a new task
It’s easy to add new cards, columns and re-order them:
Time entries
We can log time against any task
I saw the time entry had a “start” button. I was not sure if this meant my logged time was planned time or that was completed time, so I ran a test:
Basically, when we add a time with an amount, that is what we have completed already. Pressing “start” lets us add to it.
Reports
We can go to Time Reports to generate a report for any date range we desire
This gives us a nice Summary with totals
I used to do some external writing projects and training courses and I would often need to track time spent. For my professional work, often it is asked of us how much time we devoted to key quarterly initiatives - this too could be useful on that account.
Themes
There are a few nice looking themes. Here is a quick tour of the four that are there today
Ingress
I now can create an Ingress using my domain in Azure DNS
$ az account set --subscription "Pay-As-You-Go" && az network dns record-set a add-record -g idjdnsrg -z tpk.pw -a 75.73.224.240 -n eigenfocus
{
"ARecords": [
{
"ipv4Address": "75.73.224.240"
}
],
"TTL": 3600,
"etag": "35e9821a-eec4-4445-84f8-9d3ff5490b7c",
"fqdn": "eigenfocus.tpk.pw.",
"id": "/subscriptions/d955c0ba-13dc-44cf-a29a-8fed74cbb22d/resourceGroups/idjdnsrg/providers/Microsoft.Network/dnszones/tpk.pw/A/eigenfocus",
"name": "eigenfocus",
"provisioningState": "Succeeded",
"resourceGroup": "idjdnsrg",
"targetResource": {},
"trafficManagementProfile": {},
"type": "Microsoft.Network/dnszones/A"
}
I’m going to need a username and password for authentication. As we will create a K8s secret, the value should be base64’ed in the YAML
$ echo 'builder:mypassword' | tr -d '\n' | base64
YnVpbGRlcjpteXBhc3N3b3Jk
I’ll create a new Ingress with a Basic Auth secret (builder:mypassword in base64) that can be used to auth our ingress
$ cat eigenfocus.ingress.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: eigenfocus-app-data-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: eigenfocus-deployment
spec:
replicas: 1
selector:
matchLabels:
app: eigenfocus
template:
metadata:
labels:
app: eigenfocus
spec:
containers:
- name: eigenfocus
image: eigenfocus/eigenfocus:0.6.0
ports:
- containerPort: 3000
env:
- name: DEFAULT_HOST_URL
value: "https://eigenfocus.tpk.pw"
volumeMounts:
- name: app-data-volume
mountPath: /eigenfocus-app/app-data
volumes:
- name: app-data-volume
persistentVolumeClaim:
claimName: eigenfocus-app-data-pvc
---
apiVersion: v1
kind: Service
metadata:
name: eigenfocus-service
spec:
selector:
app: eigenfocus
ports:
- protocol: TCP
port: 80
targetPort: 3000
type: ClusterIP
---
apiVersion: v1
kind: Secret
metadata:
name: eigen-basic-auth
data:
auth: YnVpbGRlcjpteXBhc3N3b3Jk
type: Opaque
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
nginx.ingress.kubernetes.io/auth-type: "basic"
nginx.ingress.kubernetes.io/auth-secret: "eigen-basic-auth"
nginx.ingress.kubernetes.io/auth-realm: "Authentication Required"
cert-manager.io/cluster-issuer: azuredns-tpkpw
ingress.kubernetes.io/ssl-redirect: "true"
kubernetes.io/ingress.class: nginx
kubernetes.io/tls-acme: "true"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
name: eigenfocusingress
spec:
rules:
- host: eigenfocus.tpk.pw
http:
paths:
- backend:
service:
name: eigenfocus-service
port:
number: 80
path: /
pathType: Prefix
tls:
- hosts:
- eigenfocus.tpk.pw
secretName: eigenfocus-tls
I can now apply and it should create the secret and the ingress with the AzureDNS cluster issuer. You will note how the deployment now says configured because I went and updated the DNS entry to match the new URL
$ kubectl apply -f ./eigenfocus.ingress.yaml
persistentvolumeclaim/eigenfocus-app-data-pvc unchanged
deployment.apps/eigenfocus-deployment configured
service/eigenfocus-service unchanged
secret/eigen-basic-auth created
ingress.networking.k8s.io/eigenfocusingress created
While this exposed the app, it did not prompt for auth
$ sudo apt install apache2-utils Reading package lists… Done Building dependency tree… Done Reading state information… Done The following additional packages will be installed: libapr1t64 libaprutil1t64 The following NEW packages will be installed: apache2-utils libapr1t64 libaprutil1t64 0 upgraded, 3 newly installed, 0 to remove and 64 not upgraded. Need to get 297 kB of archives. After this operation, 907 kB of additional disk space will be used. Do you want to continue? [Y/n] y Get:1 http://archive.ubuntu.com/ubuntu noble-updates/main amd64 libapr1t64 amd64 1.7.2-3.1ubuntu0.1 [108 kB] Get:2 http://archive.ubuntu.com/ubuntu noble/main amd64 libaprutil1t64 amd64 1.6.3-1.1ubuntu7 [91.9 kB] Get:3 http://archive.ubuntu.com/ubuntu noble-updates/main amd64 apache2-utils amd64 2.4.58-1ubuntu8.5 [97.1 kB] Fetched 297 kB in 2s (145 kB/s) Selecting previously unselected package libapr1t64:amd64. (Reading database … 168813 files and directories currently installed.) Preparing to unpack …/libapr1t64_1.7.2-3.1ubuntu0.1_amd64.deb … Unpacking libapr1t64:amd64 (1.7.2-3.1ubuntu0.1) … Selecting previously unselected package libaprutil1t64:amd64. Preparing to unpack …/libaprutil1t64_1.6.3-1.1ubuntu7_amd64.deb … Unpacking libaprutil1t64:amd64 (1.6.3-1.1ubuntu7) … Selecting previously unselected package apache2-utils. Preparing to unpack …/apache2-utils_2.4.58-1ubuntu8.5_amd64.deb … Unpacking apache2-utils (2.4.58-1ubuntu8.5) … Setting up libapr1t64:amd64 (1.7.2-3.1ubuntu0.1) … Setting up libaprutil1t64:amd64 (1.6.3-1.1ubuntu7) … Setting up apache2-utils (2.4.58-1ubuntu8.5) … Processing triggers for man-db (2.12.0-4build2) … Processing triggers for libc-bin (2.39-0ubuntu8.3) …
Now I’ll try creating the entry with htpasswd
instead
builder@LuiGi:~/Workspaces/jekyll-blog$ htpasswd -c auth builder
New password:
Re-type new password:
Adding password for user builder
builder@LuiGi:~/Workspaces/jekyll-blog$ kubectl create secret generic eigenfocus-basic-auth --from-file=auth
secret/eigenfocus-basic-auth created
I’ll change the ingress file to use that new secret
$ diff eigenfocus.ingress2.yaml eigenfocus.ingress.yaml
60c60
< auth: YnVpbGRlcjpteXBhc3N3b3Jk # base64-encoded "builder:mypassword"
---
> auth: YnVpbGRlcjpteXBhc3N3b3Jk
66d65
< name: eigenfocusingress
69c68
< nginx.ingress.kubernetes.io/auth-secret: "eigenfocus-basic-auth"
---
> nginx.ingress.kubernetes.io/auth-secret: "eigen-basic-auth"
76a76
> name: eigenfocusingress
82,84c82
< - path: /
< pathType: Prefix
< backend:
---
> - backend:
88a87,88
> path: /
> pathType: Prefix
and try again
builder@LuiGi:~/Workspaces/jekyll-blog$ kubectl delete ingress eigenfocusingress
ingress.networking.k8s.io "eigenfocusingress" deleted
builder@LuiGi:~/Workspaces/jekyll-blog$ kubectl apply -f ./eigenfocus.ingress2.yaml
persistentvolumeclaim/eigenfocus-app-data-pvc unchanged
deployment.apps/eigenfocus-deployment unchanged
service/eigenfocus-service unchanged
secret/eigen-basic-auth unchanged
ingress.networking.k8s.io/eigenfocusingress created
But still no luck
I even tried swapping it over to HTTP instead of HTTPS
While it seems my Ingress Controller isn’t cooperating with the password authentication, perhaps there is a more low-tech way to solve this.
Let’s remove the ingress and existing ClusterIP service
builder@DESKTOP-QADGF36:~$ kubectl get svc eigenfocus-service -o yaml > eigenfocus.svc.yaml
builder@DESKTOP-QADGF36:~$ vi eigenfocus.svc.yaml
builder@DESKTOP-QADGF36:~$ kubectl delete svc eigenfocus-service
service "eigenfocus-service" deleted
Now add it as a NodePort
service instead
builder@DESKTOP-QADGF36:~$ cat eigenfocus.svc.yaml
apiVersion: v1
kind: Service
metadata:
name: eigenfocus-service
spec:
ports:
- port: 80
protocol: TCP
targetPort: 3000
selector:
app: eigenfocus
type: NodePort
builder@DESKTOP-QADGF36:~$ kubectl apply -f ./eigenfocus.svc.yaml
service/eigenfocus-service created
I can now see the Node Port used for this service:
$ kubectl get svc eigenfocus-service
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
eigenfocus-service NodePort 10.43.250.47 <none> 80:32201/TCP 42s
I can use a jump box like Obsidian to access it on that port remotely. The nice thing about this Obsidian instance, is I really don’t use it for that app, rather as a GUI’ed endpoint for internal apps. If you want to see how I set that up, you can read more in the Obsidian and Timesy app article here.
To finish that up, I’ll want to set the URL properly in the deployment
And of course I can access it directly in my network
Kimai
Kimai is an Open-Source time tracking app that is easy to install locally
We could start with Docker, but let’s just jump right in with their Kubernetes Helm chart
I first will add the Chart repo
$ helm repo add robjuz https://robjuz.github.io/helm-charts/
"robjuz" has been added to your repositories
Then use it to install Kimai
$ helm install kimai --set kimaiAppSecret=DKenCIKdoe830kdfc754kcme89 --set kimaiAdminEmail=isaac.johnson@gmail.com --set kimaiAdminPassword=
MyPasswordForAdmin2quitNow --set kimaiMailerFrom=isaac@freshbrewed.science --set kimaiMailerUrl=smtps://apikey:
SG.asdfjkasdf843jd8-asdfi3kjJCXHF390DIKfh-aasdf
gOC9pDIb5HMM48@smtp.sendgrid.net:465 robjuz/kimai2
NAME: kimai
LAST DEPLOYED: Wed Jan 22 05:53:31 2025
NAMESPACE: default
STATUS: deployed
REVISION: 1
TEST SUITE: None
NOTES:
CHART NAME: kimai2
CHART VERSION: 4.3.1
APP VERSION: apache-2.27.0
** Please be patient while the chart is being deployed **
Your Kimai instance can be accessed through the following DNS name from within your cluster:
kimai-kimai2.default.svc.cluster.local (port 80)
To access your Kimai instance from outside the cluster follow the steps below:
1. Get the Kimai URL by running these commands:
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
Watch the status with: 'kubectl get svc --namespace default -w kimai-kimai2'
export SERVICE_IP=$(kubectl get svc --namespace default kimai-kimai2 --template "{{ range (index .status.loadBalancer.ingress 0) }}{{ . }}{{ end }}")
echo "Kimai URL: http://$SERVICE_IP/"
2. Open a browser and access Kimai using the obtained URL.
3. Login with the following credentials below to see your blog:
echo Username: isaac.johnson@gmail.com
echo Password: $(kubectl get secret --namespace default kimai-kimai2 -o jsonpath="{.data.admin-password}" | base64 -d)
In this case, my SendGrid password is SG.asdfjkasdf843jd8-asdfi3kjJCXHF390DIKfh-aasdf
. It will always use apikey
as the user. I also set the admin password to `
MyPasswordForAdmin2quitNow`
I watched for the pods to come up
$ kubectl get po | grep kimai
kimai-kimai2-57d8f69c64-7rcjj 0/1 ContainerCreating 0 19s
kimai-mariadb-0 0/1 Running 0 19s
It took just under 2m to see the full stack come up on my cluster
$ kubectl get po | grep kimai
kimai-mariadb-0 1/1 Running 0 110s
kimai-kimai2-57d8f69c64-7rcjj 1/1 Running 0 110s
I realized I didn’t override the service type to ClusterIP, but I can circle back on that when we do Ingress
$ kubectl get svc | grep kimai
kimai-mariadb ClusterIP 10.43.51.224 <none> 3306/TCP 81s
kimai-kimai2 LoadBalancer 10.43.30.235 <pending> 80:31553/TCP 81s
To test, I’ll port-forward to the service
$ kubectl port-forward svc/kimai-kimai2 8088:80
Forwarding from 127.0.0.1:8088 -> 8001
Forwarding from [::1]:8088 -> 8001
I know the password because I set it in the helm invokation, but in case we forget, we can fetch from k8s
$ kubectl get secret --namespace default kimai-kimai2 -o jsonpath="{.data.admin-password}" | base64 -d
MyPasswordForAdmin2quitNow
I now get a welcome splash
that takes me to a setup screen
I get a Congrats page
Before landing on the main page
Let’s set up our first Customer, Project, and Activity. Then Enter our first time entry
Here you can see a report of billed hours thus far
I can click the Start button to start a new activity entry
I now see a minute counter in the upper right tracking time. It keeps the counter in the title and the upper right
Let’s create a team
I can then give the team a name and colour as well as add members
I can then pick which customers and projects to which this team has access
Reports
Let’s go to reporting and see what formats to which we can export
By default, I see all entries and it does not include the in-progress time (the timer is still ticking in my title bar)
The filter can thin the list to just those matching a time rang, or project or activity amongst other criteria
For instance, I did a “Print” report for ‘self’ for just this week
When I click the “stop” icon, I can see my entry is saved
Now I see those accounted for in the total for the week
Ingress
Let’s try to actually use Helm to expose this (I usually avoid Helm, but let’s try it)
I’ll fire up an Azure DNS entry
$ az account set --subscription "Pay-As-You-Go" && az network dns record-set a add-record -g idjdnsrg -z tpk.pw -a 75.73.224.240 -n kimai
{
"ARecords": [
{
"ipv4Address": "75.73.224.240"
}
],
"TTL": 3600,
"etag": "67a744f9-b106-499f-9caf-3efe5eaca295",
"fqdn": "kimai.tpk.pw.",
"id": "/subscriptions/d955c0ba-13dc-44cf-a29a-8fed74cbb22d/resourceGroups/idjdnsrg/providers/Microsoft.Network/dnszones/tpk.pw/A/kimai",
"name": "kimai",
"provisioningState": "Succeeded",
"resourceGroup": "idjdnsrg",
"targetResource": {},
"trafficManagementProfile": {},
"type": "Microsoft.Network/dnszones/A"
}
Now the key annotations that matter are using the right Cluster Issuer (I have 3) and ingress class name
cert-manager.io/cluster-issuer: azuredns-tpkpw
kubernetes.io/ingress.class: nginx
I tried three ways to get the annotations to take, the third seems to have worked
builder@DESKTOP-QADGF36:~/Workspaces/subscription-manager2$ helm upgrade kimai --set service.type=ClusterIP --set ingress.enabled=true --set ingress.ingressClassName=nginx --set ingress.hostname=kimai.tpk.pw --set ingress.tls=true --set ingress.annotations.cert-manager.io/cluster-issuer=azuredns-tpkpw --set kimaiAppSecret=DKenCIKdoe830kdfc754kcme89 --set kimaiAdminEmail=isaac.johnson@gmail.com --set kimaiAdminPassword=
MyPasswordForAdmin2quitNow --set kimaiMailerFrom=isaac@freshbrewed.science --set kimaiMailerUrl=smtps://apikey:
SG.asdfjkasdf843jd8-asdfi3kjJCXHF390DIKfh-aasdfgOC9pDIb5HMM48@smtp.sendgrid.net:465 robjuz/kimai2
Error: UPGRADE FAILED: YAML parse error on kimai2/templates/ingress.yaml: error unmarshaling JSON: while decoding JSON: json: cannot unmarshal object into Go struct field .metadata.annotations of type string
builder@DESKTOP-QADGF36:~/Workspaces/subscription-manager2$
builder@DESKTOP-QADGF36:~/Workspaces/subscription-manager2$
builder@DESKTOP-QADGF36:~/Workspaces/subscription-manager2$ helm upgrade kimai --set service.type=ClusterIP --set ingress.enabled=true --set ingress.ingressClassName=nginx --set ingress.hostname=kimai.tpk.pw --set ingress.tls=true --set ingress.annotations[0].key="cert-manager.io/cluster-issuer" --set ingress.annotations[0].value="azuredns-tpkpw" --set kimaiAppSecret=DKenCIKdoe830kdfc754kcme89 --set kimaiAdminEmail=isaac.johnson@gmail.com --set kimaiAdminPassword=
MyPasswordForAdmin2quitNow --set kimaiMailerFrom=isaac@freshbrewed.science --set kimaiMailerUrl=smtps://apikey:
SG.asdfjkasdf843jd8-asdfi3kjJCXHF390DIKfh-aasdfgOC9pDIb5HMM48@smtp.sendgrid.net:465 robjuz/kimai2
coalesce.go:220: warning: cannot overwrite table with non table for kimai2.ingress.annotations (map[])
coalesce.go:220: warning: cannot overwrite table with non table for kimai2.ingress.annotations (map[])
Error: UPGRADE FAILED: template: kimai2/templates/ingress.yaml:49:42: executing "kimai2/templates/ingress.yaml" at <include "common.ingress.certManagerRequest" (dict "annotations" .Values.ingress.annotations)>: error calling include: template: kimai2/charts/common/templates/_ingress.tpl:70:17: executing "common.ingress.certManagerRequest" at <.annotations>: wrong type for value; expected map[string]interface {}; got []interface {}
builder@DESKTOP-QADGF36:~/Workspaces/subscription-manager2$
builder@DESKTOP-QADGF36:~/Workspaces/subscription-manager2$
builder@DESKTOP-QADGF36:~/Workspaces/subscription-manager2$ helm upgrade kimai --set service.type=ClusterIP --set ingress.enabled=true --set ingress.ingressClassName=nginx --set ingress.hostname=kimai.tpk.pw --set ingress.tls=true --set ingress.annotations.'cert-manager\.io/cluster-issuer'=azuredns-tpkpw --set kimaiAppSecret=DKenCIKdoe830kdfc754kcme89 --set kimaiAdminEmail=isaac.johnson@gmail.com --set kimaiAdminPassword=
MyPasswordForAdmin2quitNow --set kimaiMailerFrom=isaac@freshbrewed.science --set kimaiMailerUrl=smtps://apikey:
SG.asdfjkasdf843jd8-asdfi3kjJCXHF390DIKfh-aasdfgOC9pDIb5HMM48@smtp.sendgrid.net:465 robjuz/kimai2
Release "kimai" has been upgraded. Happy Helming!
NAME: kimai
LAST DEPLOYED: Wed Jan 22 06:31:26 2025
NAMESPACE: default
STATUS: deployed
REVISION: 2
TEST SUITE: None
NOTES:
CHART NAME: kimai2
CHART VERSION: 4.3.1
APP VERSION: apache-2.27.0
** Please be patient while the chart is being deployed **
Your Kimai instance can be accessed through the following DNS name from within your cluster:
kimai-kimai2.default.svc.cluster.local (port 80)
To access your Kimai instance from outside the cluster follow the steps below:
1. Get the Kimai URL and associate Kimai hostname to your cluster external IP:
export CLUSTER_IP=$(minikube ip) # On Minikube. Use: `kubectl cluster-info` on others K8s clusters
echo "Kimai URL: https://kimai.tpk.pw/"
echo "$CLUSTER_IP kimai.tpk.pw" | sudo tee -a /etc/hosts
2. Open a browser and access Kimai using the obtained URL.
3. Login with the following credentials below to see your blog:
echo Username: isaac.johnson@gmail.com
echo Password: $(kubectl get secret --namespace default kimai-kimai2 -o jsonpath="{.data.admin-password}" | base64 -d)
I checked, and it looks correct
$ kubectl get ingress kimai-kimai2 -o yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
cert-manager.io/cluster-issuer: azuredns-tpkpw
meta.helm.sh/release-name: kimai
meta.helm.sh/release-namespace: default
creationTimestamp: "2025-01-22T12:31:30Z"
generation: 1
labels:
app.kubernetes.io/instance: kimai
app.kubernetes.io/managed-by: Helm
app.kubernetes.io/name: kimai2
app.kubernetes.io/version: apache-2.27.0
helm.sh/chart: kimai2-4.3.1
name: kimai-kimai2
namespace: default
resourceVersion: "53142336"
uid: 0971514f-4741-4f0f-8e19-6b1d3b62e51d
spec:
ingressClassName: nginx
rules:
- host: kimai.tpk.pw
http:
paths:
- backend:
service:
name: kimai-kimai2
port:
name: http
path: /
pathType: ImplementationSpecific
tls:
- hosts:
- kimai.tpk.pw
secretName: kimai.tpk.pw-tls
status:
loadBalancer: {}
When I see the cert satisified
$ kubectl get cert kimai.tpk.pw-tls
NAME READY SECRET AGE
kimai.tpk.pw-tls True kimai.tpk.pw-tls 91s
I can try the URL
Once logged in, I can see it persisted my entries
Calender
We can also use the Calendar view to see our entries
Which includes a weekly break down
Plugins
There is a pretty expansive list of free and paid plugins we can use to extend Kimai
Let’s add Timesheet Approvals
If we look at the linked Github page with install instructions
we can see that the zip needs to get into the ‘var/plugins’ folder. This might be a bit tricky with a containerized app
As we can see in the chart docs, we can use a volume mount (which should stay over a pod rotate)
At this point our helm set
values are getting a bit much, so i’ll switch to a values file for this
$ helm get values --all kimai -o yaml > kimai.values.yaml
$ helm get values --all kimai -o yaml > kimai.values.yaml.bak
I’ll then just add the extraVolumeMounts as described
$ diff kimai.values.yaml kimai.values.yaml.bak
71,74c71
< extraVolumeMounts:
< - mountPath: /opt/kimai/var/plugins
< name: kimai-data
< subPath: plugins
---
> extraVolumeMounts: []
Then use the new values
$ helm upgrade kimai -f kimai.values.yaml robjuz/kimai2
Release "kimai" has been upgraded. Happy Helming!
NAME: kimai
LAST DEPLOYED: Wed Jan 22 06:45:59 2025
NAMESPACE: default
STATUS: deployed
REVISION: 3
TEST SUITE: None
NOTES:
CHART NAME: kimai2
CHART VERSION: 4.3.1
APP VERSION: apache-2.27.0
** Please be patient while the chart is being deployed **
Your Kimai instance can be accessed through the following DNS name from within your cluster:
kimai-kimai2.default.svc.cluster.local (port 80)
To access your Kimai instance from outside the cluster follow the steps below:
1. Get the Kimai URL and associate Kimai hostname to your cluster external IP:
export CLUSTER_IP=$(minikube ip) # On Minikube. Use: `kubectl cluster-info` on others K8s clusters
echo "Kimai URL: https://kimai.tpk.pw/"
echo "$CLUSTER_IP kimai.tpk.pw" | sudo tee -a /etc/hosts
2. Open a browser and access Kimai using the obtained URL.
3. Login with the following credentials below to see your blog:
echo Username: isaac.johnson@gmail.com
echo Password: $(kubectl get secret --namespace default kimai-kimai2 -o jsonpath="{.data.admin-password}" | base64 -d)
Once the new pod comes into play
$ kubectl get po | grep kimai
kimai-mariadb-0 1/1 Running 0 49m
kimai-kimai2-86f7864cd7-c845f 1/1 Running 0 15m
kimai-kimai2-587fb76f48-p67nz 0/1 Running 0 39s
$ kubectl get po | grep kimai
kimai-mariadb-0 1/1 Running 0 49m
kimai-kimai2-587fb76f48-p67nz 1/1 Running 0 44s
kimai-kimai2-86f7864cd7-c845f 1/1 Terminating 0 15m
$ kubectl get po | grep kimai
kimai-mariadb-0 1/1 Running 0 49m
kimai-kimai2-587fb76f48-p67nz 1/1 Running 0 47s
I can hop into the pod
$ kubectl exec -it kimai-kimai2-587fb76f48-p67nz -- /bin/bash
root@kimai-kimai2-587fb76f48-p67nz:/var/www/html#
I’ll want to get to the plugins dir and add wget so we can download the plugin zip
root@kimai-kimai2-587fb76f48-p67nz:/var/www/html# cd /opt/kimai/var/plugins
root@kimai-kimai2-587fb76f48-p67nz:/opt/kimai/var/plugins# ls
root@kimai-kimai2-587fb76f48-p67nz:/opt/kimai/var/plugins# which wget
root@kimai-kimai2-587fb76f48-p67nz:/opt/kimai/var/plugins# apt update && apt install wget
Get:1 http://deb.debian.org/debian bookworm InRelease [151 kB]
Get:2 http://deb.debian.org/debian bookworm-updates InRelease [55.4 kB]
Get:3 http://deb.debian.org/debian-security bookworm-security InRelease [48.0 kB]
Get:4 http://deb.debian.org/debian bookworm/main amd64 Packages [8792 kB]
Get:5 http://deb.debian.org/debian bookworm-updates/main amd64 Packages.diff/Index [15.1 kB]
Get:6 http://deb.debian.org/debian bookworm-updates/main amd64 Packages T-2025-01-14-2009.05-F-2025-01-14-2009.05.pdiff [5693 B]
Get:6 http://deb.debian.org/debian bookworm-updates/main amd64 Packages T-2025-01-14-2009.05-F-2025-01-14-2009.05.pdiff [5693 B]
Get:7 http://deb.debian.org/debian-security bookworm-security/main amd64 Packages [241 kB]
Fetched 9309 kB in 2s (5423 kB/s)
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
12 packages can be upgraded. Run 'apt list --upgradable' to see them.
N: Repository 'http://deb.debian.org/debian bookworm InRelease' changed its 'Version' value from '12.8' to '12.9'
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
The following NEW packages will be installed:
wget
0 upgraded, 1 newly installed, 0 to remove and 12 not upgraded.
Need to get 984 kB of archives.
After this operation, 3692 kB of additional disk space will be used.
Get:1 http://deb.debian.org/debian bookworm/main amd64 wget amd64 1.21.3-1+b2 [984 kB]
Fetched 984 kB in 0s (5559 kB/s)
debconf: delaying package configuration, since apt-utils is not installed
Selecting previously unselected package wget.
(Reading database ... 14244 files and directories currently installed.)
Preparing to unpack .../wget_1.21.3-1+b2_amd64.deb ...
Unpacking wget (1.21.3-1+b2) ...
Setting up wget (1.21.3-1+b2) ...
root@kimai-kimai2-587fb76f48-p67nz:/opt/kimai/var/plugins#
I can now download and unzip the ApprovalBundle
root@kimai-kimai2-587fb76f48-p67nz:/opt/kimai/var/plugins# wget https://github.com/KatjaGlassConsulting/ApprovalBundle/archive/refs/tags/2.2.0.zip
--2025-01-22 14:06:58-- https://github.com/KatjaGlassConsulting/ApprovalBundle/archive/refs/tags/2.2.0.zip
Resolving github.com (github.com)... 140.82.113.4
Connecting to github.com (github.com)|140.82.113.4|:443... connected.
HTTP request sent, awaiting response... 302 Found
Location: https://codeload.github.com/KatjaGlassConsulting/ApprovalBundle/zip/refs/tags/2.2.0 [following]
--2025-01-22 14:06:59-- https://codeload.github.com/KatjaGlassConsulting/ApprovalBundle/zip/refs/tags/2.2.0
Resolving codeload.github.com (codeload.github.com)... 140.82.114.10
Connecting to codeload.github.com (codeload.github.com)|140.82.114.10|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: unspecified [application/zip]
Saving to: '2.2.0.zip'
2.2.0.zip [ <=> ] 4.40M 12.2MB/s in 0.4s
2025-01-22 14:06:59 (12.2 MB/s) - '2.2.0.zip' saved [4612676]
root@kimai-kimai2-587fb76f48-p67nz:/opt/kimai/var/plugins# unzip 2.2.0.zip
Archive: 2.2.0.zip
78fed5425bec247a2bbbe0e6a6da4dc9eea66790
creating: ApprovalBundle-2.2.0/
creating: ApprovalBundle-2.2.0/.github/
inflating: ApprovalBundle-2.2.0/.github/FUNDING.yml
inflating: ApprovalBundle-2.2.0/.github/linting.yaml
extracting: ApprovalBundle-2.2.0/.gitignore
inflating: ApprovalBundle-2.2.0/.php-cs-fixer.dist.php
creating: ApprovalBundle-2.2.0/API/
inflating: ApprovalBundle-2.2.0/API/ApprovalBundleApiController.php
inflating: ApprovalBundle-2.2.0/API/ApprovalNextWeekApiController.php
inflating: ApprovalBundle-2.2.0/API/ApprovalOvertimeController.php
inflating: ApprovalBundle-2.2.0/API/ApprovalOvertimeWeeklyController.php
inflating: ApprovalBundle-2.2.0/API/ApprovalStatusApiController.php
inflating: ApprovalBundle-2.2.0/ApprovalBundle.php
inflating: ApprovalBundle-2.2.0/CHANGELOG.md
creating: ApprovalBundle-2.2.0/Command/
inflating: ApprovalBundle-2.2.0/Command/AdminNotSubmittedCommand.php
inflating: ApprovalBundle-2.2.0/Command/InstallCommand.php
inflating: ApprovalBundle-2.2.0/Command/TeamleadNotSubmittedCommand.php
inflating: ApprovalBundle-2.2.0/Command/UserNotSubmittedCommand.php
creating: ApprovalBundle-2.2.0/Controller/
inflating: ApprovalBundle-2.2.0/Controller/ApprovalController.php
inflating: ApprovalBundle-2.2.0/Controller/BaseApprovalController.php
inflating: ApprovalBundle-2.2.0/Controller/OvertimeAllReportController.php
inflating: ApprovalBundle-2.2.0/Controller/OvertimeReportController.php
inflating: ApprovalBundle-2.2.0/Controller/SettingsOvertimeController.php
inflating: ApprovalBundle-2.2.0/Controller/WeekReportController.php
creating: ApprovalBundle-2.2.0/DependencyInjection/
inflating: ApprovalBundle-2.2.0/DependencyInjection/ApprovalExtension.php
creating: ApprovalBundle-2.2.0/DependencyInjection/Compiler/
inflating: ApprovalBundle-2.2.0/DependencyInjection/Compiler/ApprovalSettingsCompilerPass.php
creating: ApprovalBundle-2.2.0/Entity/
inflating: ApprovalBundle-2.2.0/Entity/Approval.php
inflating: ApprovalBundle-2.2.0/Entity/ApprovalHistory.php
inflating: ApprovalBundle-2.2.0/Entity/ApprovalOvertimeHistory.php
inflating: ApprovalBundle-2.2.0/Entity/ApprovalStatus.php
inflating: ApprovalBundle-2.2.0/Entity/ApprovalWorkdayHistory.php
creating: ApprovalBundle-2.2.0/Enumeration/
inflating: ApprovalBundle-2.2.0/Enumeration/ConfigEnum.php
inflating: ApprovalBundle-2.2.0/Enumeration/FormEnum.php
creating: ApprovalBundle-2.2.0/EventSubscriber/
inflating: ApprovalBundle-2.2.0/EventSubscriber/MenuSubscriber.php
creating: ApprovalBundle-2.2.0/Extension/
inflating: ApprovalBundle-2.2.0/Extension/FormattingExtension.php
creating: ApprovalBundle-2.2.0/Form/
inflating: ApprovalBundle-2.2.0/Form/AddOvertimeHistoryForm.php
inflating: ApprovalBundle-2.2.0/Form/AddToApprove.php
inflating: ApprovalBundle-2.2.0/Form/AddWorkdayHistoryForm.php
inflating: ApprovalBundle-2.2.0/Form/OvertimeByAllForm.php
inflating: ApprovalBundle-2.2.0/Form/OvertimeByUserForm.php
inflating: ApprovalBundle-2.2.0/Form/SettingsForm.php
inflating: ApprovalBundle-2.2.0/Form/WeekByUserForm.php
inflating: ApprovalBundle-2.2.0/LICENSE
creating: ApprovalBundle-2.2.0/Migrations/
inflating: ApprovalBundle-2.2.0/Migrations/Version20220208134542.php
inflating: ApprovalBundle-2.2.0/Migrations/Version20220210154511.php
inflating: ApprovalBundle-2.2.0/Migrations/Version20220303101010.php
inflating: ApprovalBundle-2.2.0/Migrations/Version20220303134149.php
inflating: ApprovalBundle-2.2.0/Migrations/Version20220307092555.php
inflating: ApprovalBundle-2.2.0/Migrations/Version20220318122512.php
inflating: ApprovalBundle-2.2.0/Migrations/Version20221118162725.php
inflating: ApprovalBundle-2.2.0/Migrations/Version20231016134127.php
inflating: ApprovalBundle-2.2.0/Migrations/Version20240828161654.php
inflating: ApprovalBundle-2.2.0/Migrations/approval.yaml
inflating: ApprovalBundle-2.2.0/README.md
creating: ApprovalBundle-2.2.0/Repository/
inflating: ApprovalBundle-2.2.0/Repository/ApprovalHistoryRepository.php
inflating: ApprovalBundle-2.2.0/Repository/ApprovalOvertimeHistoryRepository.php
inflating: ApprovalBundle-2.2.0/Repository/ApprovalRepository.php
inflating: ApprovalBundle-2.2.0/Repository/ApprovalStatusRepository.php
inflating: ApprovalBundle-2.2.0/Repository/ApprovalTimesheetRepository.php
inflating: ApprovalBundle-2.2.0/Repository/ApprovalWorkdayHistoryRepository.php
inflating: ApprovalBundle-2.2.0/Repository/LockdownRepository.php
inflating: ApprovalBundle-2.2.0/Repository/ReportRepository.php
creating: ApprovalBundle-2.2.0/Resources/
creating: ApprovalBundle-2.2.0/Resources/config/
inflating: ApprovalBundle-2.2.0/Resources/config/routes.yaml
inflating: ApprovalBundle-2.2.0/Resources/config/services.yaml
creating: ApprovalBundle-2.2.0/Resources/translations/
inflating: ApprovalBundle-2.2.0/Resources/translations/messages.de.xlf
inflating: ApprovalBundle-2.2.0/Resources/translations/messages.en.xlf
inflating: ApprovalBundle-2.2.0/Resources/translations/messages.hr.xlf
creating: ApprovalBundle-2.2.0/Resources/views/
inflating: ApprovalBundle-2.2.0/Resources/views/add_overtime_history.html.twig
inflating: ApprovalBundle-2.2.0/Resources/views/add_to_approve.twig
inflating: ApprovalBundle-2.2.0/Resources/views/add_workday_history.html.twig
inflating: ApprovalBundle-2.2.0/Resources/views/approved.email.twig
inflating: ApprovalBundle-2.2.0/Resources/views/approvedChangeStatus.email.twig
inflating: ApprovalBundle-2.2.0/Resources/views/closedMonth.email.twig
inflating: ApprovalBundle-2.2.0/Resources/views/cronjob.adminNotSubmitted.email.twig
inflating: ApprovalBundle-2.2.0/Resources/views/cronjob.teamleadNotSubmittedUsers.email.twig
inflating: ApprovalBundle-2.2.0/Resources/views/cronjob.userNotSubmittedWeeks.email.twig
inflating: ApprovalBundle-2.2.0/Resources/views/layout.html.twig
inflating: ApprovalBundle-2.2.0/Resources/views/macros.html.twig
inflating: ApprovalBundle-2.2.0/Resources/views/navigation.html.twig
inflating: ApprovalBundle-2.2.0/Resources/views/overtime_by_all.html.twig
inflating: ApprovalBundle-2.2.0/Resources/views/overtime_by_user.html.twig
inflating: ApprovalBundle-2.2.0/Resources/views/report_by_user.html.twig
inflating: ApprovalBundle-2.2.0/Resources/views/settings.html.twig
inflating: ApprovalBundle-2.2.0/Resources/views/settings_overtime_history.html.twig
inflating: ApprovalBundle-2.2.0/Resources/views/settings_workday_history.html.twig
inflating: ApprovalBundle-2.2.0/Resources/views/to_approve.html.twig
creating: ApprovalBundle-2.2.0/Scripts/
inflating: ApprovalBundle-2.2.0/Scripts/skr_kimai_approval_check.sh
creating: ApprovalBundle-2.2.0/Settings/
inflating: ApprovalBundle-2.2.0/Settings/ApprovalSettingsInterface.php
inflating: ApprovalBundle-2.2.0/Settings/DefaultSettings.php
inflating: ApprovalBundle-2.2.0/Settings/MetaFieldSettings.php
creating: ApprovalBundle-2.2.0/Toolbox/
inflating: ApprovalBundle-2.2.0/Toolbox/BreakTimeCheckToolGER.php
inflating: ApprovalBundle-2.2.0/Toolbox/EmailTool.php
inflating: ApprovalBundle-2.2.0/Toolbox/FormTool.php
inflating: ApprovalBundle-2.2.0/Toolbox/Formatting.php
inflating: ApprovalBundle-2.2.0/Toolbox/FormattingTool.php
inflating: ApprovalBundle-2.2.0/Toolbox/SecurityTool.php
inflating: ApprovalBundle-2.2.0/Toolbox/SettingsTool.php
creating: ApprovalBundle-2.2.0/_documentation/
inflating: ApprovalBundle-2.2.0/_documentation/ApprovalAdmin.gif
inflating: ApprovalBundle-2.2.0/_documentation/ApprovalLockdown.png
inflating: ApprovalBundle-2.2.0/_documentation/ApprovalTeamlead.gif
inflating: ApprovalBundle-2.2.0/_documentation/ApprovalUser.gif
inflating: ApprovalBundle-2.2.0/_documentation/Screenshot_AdminRollbackOption.png
inflating: ApprovalBundle-2.2.0/_documentation/Screenshot_Settings.png
inflating: ApprovalBundle-2.2.0/_documentation/Screenshot_TeamleadApproveDeny.png
inflating: ApprovalBundle-2.2.0/_documentation/Screenshot_TeamleadOverviewOfTeam.png
inflating: ApprovalBundle-2.2.0/_documentation/Screenshot_TeamleadSeeHistory.png
inflating: ApprovalBundle-2.2.0/_documentation/Screenshot_UserApprovalForWeek.png
inflating: ApprovalBundle-2.2.0/_documentation/Screenshot_breaktimeRules.png
inflating: ApprovalBundle-2.2.0/_documentation/Screenshot_settingsWorkdays.png
inflating: ApprovalBundle-2.2.0/_documentation/troubleshoot_db_approval_status.png
inflating: ApprovalBundle-2.2.0/composer.json
inflating: ApprovalBundle-2.2.0/doc_troubleshooting.md
inflating: ApprovalBundle-2.2.0/documentation.md
inflating: ApprovalBundle-2.2.0/phpstan.neon
root@kimai-kimai2-587fb76f48-p67nz:/opt/kimai/var/plugins# rm 2.2.0.zip
root@kimai-kimai2-587fb76f48-p67nz:/opt/kimai/var/plugins#
I can now reload
root@kimai-kimai2-587fb76f48-p67nz:/opt/kimai/var/plugins# cd ../..
root@kimai-kimai2-587fb76f48-p67nz:/opt/kimai# ls
CHANGELOG.md Dockerfile README.md UPGRADING-1.md bin composer.lock eslint.config.mjs migrations public symfony.lock translations vendor
CONTRIBUTING.md LICENSE SECURITY.md UPGRADING.md composer.json config kimai.sh php-cli.ini src templates var version.txt
root@kimai-kimai2-587fb76f48-p67nz:/opt/kimai# bin/console kimai:reload
Install did not work, let me try moving to just a folder without version
root@kimai-kimai2-587fb76f48-p67nz:/opt/kimai/var/plugins# mv ApprovalBundle-2.2.0 ApprovalBundle
That was it! That now worked to install
root@kimai-kimai2-587fb76f48-p67nz:/opt/kimai# bin/console kimai:bundle:approval:install
Starting installation of plugin: ApprovalBundle ...
===================================================
[notice] Migrating up to ApprovalBundle\Migrations\Version20240828161654
[notice] finished in 704.2ms, used 20M memory, 9 migrations executed, 26 sql queries
[OK] Successfully migrated to version: ApprovalBundle\Migrations\Version20240828161654
[OK] Congratulations! Plugin was successful installed: ApprovalBundle
root@kimai-kimai2-587fb76f48-p67nz:/opt/kimai#
I can see a new timesheet approval section
Summary
Today we looked at two good options for Open-Source time trackers, Kimai and EigenFocus. EigenFocus is a good lightweight option best suited for local Docker. As it doesn’t have password auth or the idea of user accounts, it really wouldn’t work for a larger roll out. But I found the interface very clean and responsive.
Kimai is a bit more expansive in features. It has user accounts out of the box and a rich plugin repository one can use to expand it. I really had no issues installing it and I think the 3 to 4 Euro price for a hosted option is more than reasonable.