6 Commits
Author SHA1 Message Date
nicholas 26d7b3d06a unfinished post 2026-08-04 19:14:02 -05:00
nicholas e53dd0a193 ☎️ Local Voice Agent
Build & Push Hugo Site Image / Build & Push Image (push) Successful in 25s
2026-08-04 19:11:12 -05:00
nicholas 95ae165232 deploy web log with k3s
Build & Push Hugo Site Image / Build & Push Image (push) Successful in 26s
2026-08-04 16:39:50 -05:00
nicholas 45b11ce88a add analytics script
Build & Push Hugo Site Image / Build & Push Image (push) Successful in 19s
2026-07-09 16:08:51 -05:00
nicholas 303476e1c2 Grid Gallery
Build & Push Hugo Site Image / Build & Push Image (push) Successful in 18s
2026-07-09 14:08:24 -05:00
nicholas bb0eafec78 🌿 2026 Herb Garden
Build & Push Hugo Site Image / Build & Push Image (push) Successful in 18s
2026-07-09 12:33:28 -05:00
37 changed files with 609 additions and 53 deletions
+37 -1
View File
@@ -24,7 +24,7 @@ jobs:
- name: ⚙️ Setup Hugo
uses: peaceiris/actions-hugo@v3
with:
hugo-version: '0.161.1'
hugo-version: '0.164.0'
extended: true
- name: 🏗️ Build Hugo site
@@ -43,6 +43,42 @@ jobs:
- name: 🚀 Push Docker image
run: docker push $IMAGE_REGISTRY/$IMAGE_NAME:$IMAGE_TAG
- name: Install kubectl
env:
KUBECTL_VERSION: v1.36.2
run: |
set -euo pipefail
curl -fsSLO "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl"
curl -fsSLO "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl.sha256"
printf '%s %s\n' "$(cat kubectl.sha256)" kubectl | sha256sum --check
sudo install -m 0755 kubectl /usr/local/bin/kubectl
- name: Configure Kubernetes access
env:
KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}
run: |
set -euo pipefail
mkdir -p ~/.kube
printf '%s' "$KUBE_CONFIG" | base64 --decode > ~/.kube/config
chmod 600 ~/.kube/config
kubectl auth can-i update deployments --namespace nicholas
- name: Deploy web log and files service
run: |
set -euo pipefail
sed -E -i "s#newTag: [0-9a-f]+#newTag: ${IMAGE_TAG}#" k8s/kustomization.yaml
grep -q "newTag: ${IMAGE_TAG}$" k8s/kustomization.yaml
kubectl apply --kustomize k8s
if ! kubectl rollout status deployment/web-log \
--namespace nicholas --timeout=180s; then
kubectl rollout undo deployment/web-log --namespace nicholas
kubectl rollout status deployment/web-log \
--namespace nicholas --timeout=180s
exit 1
fi
kubectl rollout status deployment/web-log-files \
--namespace nicholas --timeout=180s
- name: 🧹 Prune images
if: always()
+41
View File
@@ -0,0 +1,41 @@
.gallery-new {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 4px;
margin: 1.25rem 0;
}
.gallery-new figure {
aspect-ratio: 1 / 1;
background: #f2f2f2;
margin: 0;
overflow: hidden;
position: relative;
}
.gallery-new .lightgallery {
aspect-ratio: 1 / 1;
display: block;
height: 100%;
overflow: hidden;
width: 100%;
}
.gallery-new .lightgallery img {
display: block;
height: 100% !important;
max-width: none;
object-fit: cover !important;
transition: filter 160ms ease, transform 160ms ease;
width: 100% !important;
}
.gallery-new .lightgallery:focus-visible img,
.gallery-new .lightgallery:hover img {
filter: brightness(0.9);
transform: scale(1.03);
}
.gallery-new figcaption {
display: none;
}
@@ -1,4 +1,4 @@
+++
+++
categories = ["plants"]
date = 2024-04-23T13:53:30Z
description = ""
@@ -30,8 +30,8 @@ Each time I visited to help water and prune, I tried to remember to photograph t
## Photos
### Vegetable Garden
{{< gallerynew name="vegetable-garden-gallery" >}}
{{< gallery name="vegetable-garden-gallery" >}}
## Herb Garden
{{< gallerynew name="herb-garden-gallery" >}}
{{< gallery name="herb-garden-gallery" >}}
@@ -1,4 +1,4 @@
+++
+++
categories = ["food"]
date = 2025-12-07T09:00:00Z
description = ""
@@ -33,4 +33,4 @@ The process is:
## Images
{{< gallerynew name="par-bake-pizza-dough-gallery" >}}
{{< gallery name="par-bake-pizza-dough-gallery" >}}
@@ -1,4 +1,4 @@
+++
+++
categories = ["build"]
date = 2025-12-10T20:03:00-06:00
description = ""
@@ -29,4 +29,4 @@ Computer desk needs tidying cables too. I attached a spare wire shelf to the und
alt="View under desk" >}}
## Images
{{< gallerynew name="server-rack-gallery" >}}
{{< gallery name="server-rack-gallery" >}}
@@ -1,4 +1,4 @@
+++
+++
categories = ["plants"]
date = 2025-02-20T10:30:00-06:00
description = ""
@@ -13,18 +13,18 @@ It is nearly springtime as I write the first lines of this post, so it is time t
- [Vegetable](#-vegetable-garden)
## 🌰 Seeds
{{< gallerynew name="seed-gallery" >}}
{{< gallery name="seed-gallery" >}}
## 🌱 Seedlings
The seeds were started in little clumps of coco coir pellets and kept beneath a LED grow light.
{{< gallerynew name="seedling-gallery" >}}
{{< gallery name="seedling-gallery" >}}
## 🌿 Herb Garden
The herb garden lives outside on the deck. It will contain garlic chives and basil. They make great pizza toppings and garnishes.
{{< gallerynew name="herb-garden-gallery" >}}
{{< gallery name="herb-garden-gallery" >}}
## 🍅 Vegetable Garden
{{< gallerynew name="vegetable-garden-gallery" >}}
{{< gallery name="vegetable-garden-gallery" >}}
@@ -0,0 +1,5 @@
images:
- src: "images/flower-gallery/flowers-05-15.JPG"
- src: "images/flower-gallery/flowers-05-28.JPG"
- src: "images/flower-gallery/flowers-06-11.JPG"
- src: "images/flower-gallery/flowers-07-09.JPG"
@@ -0,0 +1,4 @@
images:
- src: "images/herb-gallery/herb-garden-05-28.JPG"
- src: "images/herb-gallery/herb-garden-06-11.JPG"
- src: "images/herb-gallery/herb-garden-06-22.JPG"
@@ -0,0 +1,39 @@
+++
categories = ["plants"]
date = 2026-07-09T10:00:00-05:00
description = ""
draft = false
slug = "2026-07-09-herb-garden"
title = "🌿 2026 Herb Garden"
author = "nicholas"
+++
All the classic herbs this year. Everything did O.K. except cilantro (or oregano? I do not know) since it rained heavily for a couple weeks and the roof drainage repeatedly dumped a bajillion gallons of water directly on top of this section all at once, drowning them...so not a great yield. It is no great loss if cilantro.
As a child I was occasionally served a certain type of frozen pizza (Totino's Party Pizza, perhaps?) as a special treat, which to me tasted faintly of soap. I would tell my mother this each time, she would taste it for herself insisting that she rinsed her hands of all traces of soap this time, then report tasting nothing unusual. We all shrug and move on, repeat.
Much later it occured to me I had the cilantro taste-like-soap gene. Although are we sure it does not taste like "soap" to everyone and we are all just talking past one another? The phenomenology is weak here. Picture an apple, does it have color? Does it shine? Can you rotate it in 6 dimensional hyper-space? What is it like to be a bat? Man, I don't know.
## Herbs
Anyway, the herbs:
- parsley
- dill
- cilantro
- basil
- chives
- green onion
{{< gallery name="herb-gallery" >}}
We use basically all of these herbs in stuffed grape leaves. Basil on the pizza and spaghetti. Garnish this-and-that with green onion and chives.
## Flowers
Bonus flower pot this year, assorted flowers:
{{< gallery name="flower-gallery" >}}
## Misc
{{< gallery name="misc-gallery" >}}
@@ -0,0 +1,6 @@
images:
- src: "images/herb-harvest.JPG"
- src: "images/hibiscus-flowers.JPG"
- src: "images/pink-flower-close.JPG"
- src: "images/birds-flowers.JPG"
- src: "images/flowers-bloom.JPG"
@@ -0,0 +1,17 @@
+++
categories = ["vehicle"]
date = 2026-07-06T20:00:00Z
description = ""
draft = false
slug = "2026-07-06-replacing-purge-valve"
title = "💨 Replacing a Stuck-Open Purge Valve"
author = "nicholas"
+++
This car is a money pit to be sure. I have replaced this exact component once before, under exactly the same circumstances. The same set of symtoms: rough idle, RPM guage bouncing around a little, stumbles and shakes like a pathetic little wet rat upon starting for the first time after refueling.
The part functioned for a few years, and here we are again. Luckily it is stupid easy to replace, and apparently dirt cheap on Amazon. I paid something like 50 USD for this part only for it to fail after a couple of years. I shudder to think how long a $15 part will last...
{{< image
src="images/amazon-part.png"
caption="A very inexpensive car part" >}}
@@ -0,0 +1,186 @@
+++
categories = ["software"]
tags = ["voice-agent","freepbx","livekit","sip","ollama","openwebui","speech-to-text","text-to-speech"]
date = 2026-08-03T10:00:00-05:00
description = "Building a self-hosted telephone voice agent with FreePBX, LiveKit, streaming speech recognition, local language models, and retrieval-backed specialist voices."
draft = false
slug = "2026-08-03-local-voice-agent"
title = "☎️ Local Voice Agent"
author = "nicholas"
+++
In a [previous post]({{< relref "posts/2026-03-01-voip" >}}), I built a small telephone system with FreePBX and a low-cost SIP carrier, BulkVS. This gave me a real phone number, extensions, softphones, a ring group, and voicemail. This would serve as the perfect entry point for a self-hosted voice agent.
I have configured the system such that when my phone number is dialed, a voice describes a telephone menu where the caller can dial a selection:
- `1` to speak with an AI bot about me,
- `2` to leave voicemail, or
- `3` to try to reach me (ring my ring group, which are each of my IP phone extensions)
This is still a hobby project. It is not a customer-service platform, or a production-ready architecture. It is, however, a complete working system and a useful experiment in real-time audio, local AI, retrieval, and telephone routing.
## What I Wanted
I wanted the system to meet a few requirements:
- Keep the telephone system in existing FreePBX.
- Run speech recognition, the language model, and speech synthesis on my own modest hardware.
- Answer questions only from information that I intentionally published.
- Route different subjects to agents with different voices and knowledge bases.
- Preserve normal telephone features such as voicemail and a ring group.
I did not want the language model to control the initial phone menu. The first decision is deterministic: the caller presses a number, and the router follows a fixed route. The AI becomes involved only after the caller selects the conversational option.
## Architecture
The more or less complete path:
```text
Public telephone network
SIP carrier (BulkVS)
FreePBX / Asterisk
LiveKit SIP
LiveKit room
Deterministic telephone router
Python knowledge agent
├── Silero voice activity detection
├── sherpa-onnx + NVIDIA Nemotron streaming speech recognition
├── Open WebUI retrieval and model management
├── Ollama + Granite language model
└── Kokoro speech synthesis
The same path back to the caller
```
FreePBX remains the public telephone system. It owns the inbound route, voicemail, the ring group, and the connection to the SIP carrier. The AI services are on one of my local application hosts with access to my GPU. FreePBX sends only the voice-agent route to a private LiveKit SIP trunk, so I do not expose LiveKit's SIP or media ports directly to the internet.
LiveKit converts the SIP call into a room with participants and audio tracks. A small Python router, which I call the Usher, joins first. It is a deterministic menu application, not a language-model agent: it has no speech recognition, language model, tools, or autonomous routing decisions. It plays fixed menu options and receives telephone keypad events.
If the caller presses `1`, the telephone router dispatches the knowledge agent into the same room. The system plays a short telephone routing tone to indicate the handoff. The Usher remains in the room without speaking so it can retain the active-call lease, enforce the call-duration limit, and clean up when the caller disconnects. If the caller selects voicemail or my ring group, LiveKit sends the call back to the appropriate FreePBX destination with a SIP transfer.
## The House Steward and Specialists
The first conversational profile is the House Steward. Its greeting is deliberately simple, it will ask something simple like "Hello. Question about Nicholas?" This general profile can explain that the caller is speaking with an AI, describe what the service does, and answer broad questions.
The specialists cover subjects such as bicycling, books, career history, cooking, gardening, movies, music, photography, quotations, self-hosted services, travel, and early life info. This list will probably change. Each profile has its own:
- instructions
- Open WebUI model wrapper
- knowledge base
- retrieval rules
- Kokoro voice
A separate local classifier reads a completed caller turn and a small amount of recent conversation. It returns one profile and a confidence score. A high-confidence change causes a handoff inside the existing LiveKit session. The current voice announces the transfer, a short telephone routing tone plays, and the new voice gives a short introduction. The room and conversation remain intact.
## Published Knowledge
The personal information does not live in the voice-agent image or its prompt. I keep published Markdown and other data files in a separate knowledge repository. Each conversational profile has exactly one dedicated Open WebUI knowledge base. A synchronization workflow copies each published file into the appropriate knowledge bases. The general profile's knowledge base contains all published personal material, while each specialist's knowledge base contains a smaller subject-specific selection. A document can appear in several knowledge bases when it is relevant to several profiles.
For an ordinary completed turn that does not trigger a handoff, the Python agent makes three requests:
```text
Caller transcript
├── 1. Ask the local Ollama classifier to select a profile
│ ↓
│ profile + confidence
├── 2. Search the active profile's Open WebUI knowledge base
│ ↓
│ relevant excerpts
└── 3. Send the question, excerpts, and conversation to the model
answer text
```
If classification selects another profile with sufficient confidence, the fixed handoff sequence consumes that turn instead of performing retrieval and generating an answer. Otherwise, the agent limits and deduplicates the retrieved excerpts before adding them to the temporary context. The profile prompt tells the model not to invent personal details that are absent from those excerpts. Open WebUI owns the knowledge bases, embeddings, access grants, and named model configuration. Ollama runs the local Granite model.
## Open WebUI Configuration
Each conversational profile has a corresponding Open WebUI custom model wrapper attached only to that profile's knowledge base.
Every wrapper uses `granite4.1:3b` as its Ollama base model, with an 8,192-token context and a 96-token response limit. The wrapper gives the profile a stable model ID and supplies its role-specific system prompt, attached knowledge base, and model parameters. Retrieved evidence is added at request time.
Access is managed through two Open WebUI groups. The knowledge-synchronization account can write to the knowledge bases, while the voice-agent account receives read access to the managed knowledge bases and model wrappers. For each voice profile, the built-in knowledge tool is enabled and scoped to that profile's single attached knowledge base. Web search, memory, chat history, code execution, terminal access, image generation, notes, tasks, calendars, automations, custom tools, skills, filters, and actions are disabled so the model stays very dumb and safe.
The active profile selects its matching knowledge base for retrieval. Because the knowledge repository and synchronization workflow form a separate publication boundary, I can update published information without rebuilding the voice-agent container; drafts and private notes are not synchronized.
The general profile acts as a sort of FAQ for questions about the call itself. It can explain the AI identity, transfers, limits, logging, voicemail, knowledge boundaries, etc.
## Streaming Speech Recognition
Speech recognition runs through `sherpa-onnx` with NVIDIA's Nemotron 3.5 streaming model on my NVIDIA GPU.
A streaming speech recognizer's "final" result does not necessarily mean that the caller's entire conversational turn is finished. It means that a portion of the transcript is stable. A caller can speak a long sentence with a pause and produce several final transcript fragments.
LiveKit collects those stable fragments into one turn. Two timing thresholds coordinate this (values given here change as I tune to my liking):
- sherpa-onnx finalizes a stable segment after some time (something like 0.8 seconds) of trailing silence
- Silero marks the end of the caller's turn after, say, 1.5 seconds of silence
The gap gives the final transcript fragment time to reach LiveKit before the turn is committed. LiveKit can therefore combine several final fragments produced during one speaking state and send the complete request to the classifier.
## Interruptions and Handoffs
Telephone conversation is mostly open-ended. A caller can interrupt the agent while it is answering, change subjects during model generation, or ask the system to stop.
The agent tracks a generation number for each caller turn. When the caller starts speaking, it invalidates older work and interrupts active generated speech. Retrieval or model output from an older generation is discarded before it can reach the caller. The words `stop` and `cancel` are also handled as direct cancellation commands.
Handoffs use a stricter sequence. The current profile finishes its fixed transfer sentence, the system changes the active instructions, model, knowledge base, and voice, and then the receiving profile speaks. These fixed handoff messages are not generated by the language model. This prevents two profiles from speaking over each other or a delayed response from the old profile appearing after the transfer.
## Public-Call Safeguards
Connecting any automated service to a public telephone number creates opportunities for accidents and abuse. I added limits before routing the public DID to the agent:
- one active public AI call at a time;
- a maximum of 15 minutes per call;
- a maximum of 60 minutes per caller number per UTC day;
- caller IDs that parse as valid United States numbers only;
- a 60-second inactivity timeout; and
- fixed voicemail and ring-group routes outside the language model.
Redis holds the active-call lease and daily counters. Caller numbers are normalized for validation and hashed for the usage-counter keys. This is caller-ID validation, not a determination of the caller's physical location: caller ID is not authentication and can be spoofed. These controls limit casual abuse, while carrier-side restrictions remain useful defense in depth.
The service does not save an audio recording of the AI conversation. It does log final caller and agent transcripts, the complete retrieved excerpts, the assembled model-message context, timing data, warnings, and errors. Some diagnostic values are logged in both plain text and base64; base64 is only another representation, not encryption or privacy protection. Logs rotate daily and normally retain seven daily rotations. They are excluded from backup archives. Voicemail is different: when the caller chooses voicemail, FreePBX intentionally records the message and sends the normal voicemail notification.
## Performance
The system normally feels conversational, but it is not instant. A typical completed turn contains several distinct delays:
1. 1.5 seconds of silence to determine that the caller has finished.
2. A few hundred milliseconds for topic classification.
3. Usually tens of milliseconds for local knowledge retrieval.
4. Model time to the first generated token.
5. Kokoro time to the first audio frame.
In recent calls, the complete delay before speech commonly landed around three to five and a half seconds. The largest variable was often text-to-speech startup, which sometimes took more than two seconds. The local Granite model usually began producing text quickly, but a fast model does not make the whole voice pipeline fast.
I added per-stage metrics because a single "the agent is slow" measurement is not actionable. End-of-turn detection, transcription, classification, retrieval, model generation, and speech synthesis need separate timing records.
## Current Limitations
The system is operational, but it is not finished.
Speech recognition sometimes loses the end of a word. Topic classification can still select the wrong specialist. The small local model can ignore a grounding instruction, invent an unsupported recipe step, or produce an answer that is too long. Things like this. A hard token limit can stop a long answer in the middle of a sentence, which sounds particularly bad on a telephone call.
There are also two configuration layers: the Python runtime supplies current turn instructions, while Open WebUI supplies named-model configuration and permissions. Those layers must remain aligned.
## Was This the Simple Way?
No. A hosted voice-agent API could replace much of this stack. My goal, however, was to connect the phone system I already built to speech and language models running on my own hardware. For that goal, the separation now makes sense:
- FreePBX owns telephone behavior.
- LiveKit owns real-time media and agent sessions.
- sherpa-onnx owns streaming transcription.
- Open WebUI owns published knowledge and model access.
- Ollama owns local language-model inference.
- Kokoro owns speech synthesis.
- The Python agent owns routing, grounding, handoffs, and cancellation.
✅ works.
@@ -1,4 +1,4 @@
+++
+++
categories = ["food"]
date = 2024-10-16T20:27:44Z
description = ""
@@ -13,5 +13,5 @@ author = "nicholas"
> — **Old man**, passing encounter at Sam's Club
## Photos of my Pizzas
{{< gallerynew name="american-pizza-gallery" >}}
{{< gallery name="american-pizza-gallery" >}}
@@ -1,4 +1,4 @@
+++
+++
categories = ["travel"]
date = 2025-06-07T12:09:00-06:00
description = ""
@@ -28,4 +28,4 @@ Bailey is enrolled in a Master's program at Johns Hopkins School of Public Healt
loop="true" >}}
## Photo Gallery
{{< gallerynew name="baltimore-gallery" >}}
{{< gallery name="baltimore-gallery" >}}
@@ -1,4 +1,4 @@
+++
+++
categories = ["food"]
date = 2025-03-01T06:27:00-06:00
description = ""
@@ -10,7 +10,7 @@ author = "nicholas"
We made a pumpkin caramel pie to bring for a family dinner (not pictured, strangely). Enough pie crust remained to line a shallow pizza pan, which I baked into a crust before I knew what it would become. We also had a bit of caramel leftover. After a little time considering what ingredients we had, Bailey figured we could make a decent banoffee pie. It is a beautiful accident that she had been wanting to make this after seeing it on *The Great British Bake Off*, and luckier still that we had bananas on hand.
{{< gallerynew name="banoffee-pie-gallery" >}}
{{< gallery name="banoffee-pie-gallery" >}}
## Recipe
This is a lazy recipe because the food was assembled ad-hoc using leftovers of unknown measurements as is the case for many kinds of pies. I use the examples of the quiche and pie to make my point: they serve as the uniting force that brings together really any ingredient in any amount that you happen to have lying around. Details unimportant.
+2 -2
View File
@@ -1,4 +1,4 @@
+++
+++
categories = ["travel"]
date = 2024-12-29T00:00:00Z
description = ""
@@ -13,4 +13,4 @@ We went to Washington, D.C. for the [Bloomberg Public Health Summit](https://ame
## Photos
{{< gallerynew name="dcnyc" >}}
{{< gallery name="dcnyc" >}}
@@ -1,4 +1,4 @@
+++
+++
categories = ["food"]
date = 2025-01-05T00:00:00Z
description = ""
@@ -11,4 +11,4 @@ author = "nicholas"
Bailey made a cake, and I took some photos since her creations are always very beautiful. Nothing else going on here!
## Photos
{{< gallerynew name="earl-grey-gallery" >}}
{{< gallery name="earl-grey-gallery" >}}
@@ -1,4 +1,4 @@
+++
+++
categories = ["travel"]
date = 2023-01-02T12:00:00-06:00
description = ""
@@ -19,4 +19,4 @@ author = "nicholas"
## Photo Gallery
{{< gallerynew name="estes-park-gallery" >}}
{{< gallery name="estes-park-gallery" >}}
@@ -1,4 +1,4 @@
+++
+++
categories = ["travel"]
date = 2025-01-13T20:27:44Z
description = ""
@@ -10,4 +10,4 @@ author = "nicholas"
We had our honeymoon in Key West 2025-01-13 - 2025-01-17. I did not take many photos.
{{< gallerynew name="key-west-gallery" >}}
{{< gallery name="key-west-gallery" >}}
@@ -1,4 +1,4 @@
+++
+++
categories = ["food"]
date = 2025-02-22T13:40:00-06:00
description = ""
@@ -14,5 +14,5 @@ Bailey made muffins. We only have one egg left. The recipe calls for two eggs, o
src="videos/timelapse-bake-mixed-berry-muffins.webm"
width="100%" >}}
{{< gallerynew name="mixed-berry-muffin-gallery">}}
{{< gallery name="mixed-berry-muffin-gallery">}}
@@ -1,4 +1,4 @@
+++
+++
categories = ["build"]
date = 2024-11-15T17:43:02Z
description = ""
@@ -23,4 +23,4 @@ My NAS server only has 16 GB RAM. I upgrade this to 32 GB. upgrade the OS disk a
## Upgrading
In a previous log entry I documented the build process of my storage server. At the time its only intended purpose was managing my file storage and hosting a media streaming service. Its purpose has expanded to a degree that it begs for additional RAM. I will double the RAM from 16GB to 32 GB, reaching the limitations of the motherboard. I will replace the WD Blue SA510 SSD storage drive, (which will replace a very slow mechanical drive in a laptop), with a Samsung 970 EVO Plus. Old and new components will be installed in a new chassis Rosewill RSV-R4000U and rack-mounted alongside the other computer.
{{< gallerynew name="nas-build-gallery" >}}
{{< gallery name="nas-build-gallery" >}}
@@ -1,4 +1,4 @@
+++
+++
categories = ["travel"]
date = 2023-05-31T12:00:00-05:00
description = ""
@@ -22,5 +22,5 @@ Bailey and I went to PNW coast for a few days.
## Photo Gallery
{{< gallerynew name="pnw-gallery" >}}
{{< gallery name="pnw-gallery" >}}
@@ -1,4 +1,4 @@
+++
+++
categories = ["food"]
tags = ["cookie"]
date = 2025-03-21T10:28:00-06:00
@@ -23,4 +23,4 @@ This is stupid easy recipe Bailey made.
Note: The units for all weights are in grams.
{{< gallerynew name="pistachio-shortbread-cookie-gallery" >}}
{{< gallery name="pistachio-shortbread-cookie-gallery" >}}
+2 -2
View File
@@ -1,4 +1,4 @@
+++
+++
categories = ["food"]
date = 2024-04-30T17:17:58Z
description = ""
@@ -14,7 +14,7 @@ This is not traditional or authentic pita I made this bread to be strong, pliabl
src="videos/pita-baking-timelapse.webm"
width="100%" >}}
{{< gallerynew name="pita-gallery" >}}
{{< gallery name="pita-gallery" >}}
## Process
### 1. Preheat Steel Plate
@@ -1,4 +1,4 @@
+++
+++
categories = ["build"]
date = 2024-11-15T17:43:15Z
description = ""
@@ -43,7 +43,7 @@ I compared a lot of server racks, and most were too expensive to consider. The r
## Photos
I have some photos of the rack and its components. It is tucked under the desk in the corner of the apartment living room. Very pleased. Improvements are in order for the external cable management once everything is "stable".
{{< gallerynew name="server-rack-gallery" >}}
{{< gallery name="server-rack-gallery" >}}
## "Servers"
I have two chassis mounted in the rack. I have a couple of posts relating to these machines:
@@ -1,4 +1,4 @@
+++
+++
categories = ["build"]
tags = [""]
date = 2025-05-02T12:00:00-06:00
@@ -69,6 +69,6 @@ I will have a *Home Depot* employee to cut the board for me in the store. It wil
## Images
{{< gallerynew name="whiteboard-gallery" >}}
{{< gallery name="whiteboard-gallery" >}}
Done.
+2 -1
View File
@@ -6,7 +6,7 @@ timeout = 60000
# website title
title = "log - Nicholas Ward"
# language code ["en", "zh-CN", "fr", "pl", ...]
languageCode = "en"
locale = "en"
# language name ["English", "简体中文", "Français", "Polski", ...]
languageName = "English"
# whether to include Chinese/Japanese/Korean
@@ -476,6 +476,7 @@ ignoreErrors = ["error-remote-getjson", "error-missing-instagram-accesstoken"]
# located in "assets/"
# Or
# someJavascript = "https://cdn.example.com/some.js"
analytics = '''<script defer src="https://analytics.uuard.com/script.js" data-website-id="12c78397-e26b-4311-ba0a-3ce72c40716a"></script>'''
# Page SEO config
[params.page.seo]
# image URL
@@ -3,15 +3,22 @@
{{ $file := printf "%s.yaml" $galleryName }}
{{ $galleryData := .Page.Resources.GetMatch $file | transform.Unmarshal }}
<div class="gallery grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
<div class="gallery gallery-new">
{{ range $galleryData.images }}
{{ $imageURL := printf "%s/%s/%s" site.Params.filesBase $slug .src }}
<figure class="{{ .class | default "" }}">
<a
class="lightgallery"
href="{{ $imageURL }}"
data-thumbnail="{{ $imageURL }}"
{{ with .caption }}data-sub-html="{{ . }}"{{ end }}>
<img
src="{{ site.Params.filesBase }}/{{ $slug }}/{{ .src }}"
src="{{ $imageURL }}"
alt="{{ .alt | default "" }}"
loading="lazy"
decoding="async" />
</a>
{{ with .caption }}
<figcaption>{{ . }}</figcaption>
{{ end }}
+16 -8
View File
@@ -1,16 +1,24 @@
{{ $src := .Get "src" }}
{{ $alt := .Get "alt" }}
{{ $slug := .Page.Slug }}
{{ $imageURL := printf "%s/%s/%s" site.Params.filesBase $slug $src }}
{{ $caption := .Get "caption" }}
<figure>
<img
src="{{ site.Params.filesBase }}/{{ $slug }}/{{ $src }}"
alt="{{ $alt }}"
loading="lazy"
decoding="async"
width="{{ .Get "width" | default "800" }}" />
<a
class="lightgallery"
href="{{ $imageURL }}"
data-thumbnail="{{ $imageURL }}"
{{ with $caption }}data-sub-html="{{ . }}"{{ end }}>
<img
src="{{ $imageURL }}"
alt="{{ $alt }}"
loading="lazy"
decoding="async"
width="{{ .Get "width" | default "800" }}" />
</a>
{{ with .Get "caption" }}
{{ with $caption }}
<figcaption>{{ . }}</figcaption>
{{ end }}
</figure>
</figure>
+13
View File
@@ -0,0 +1,13 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: nicholas
resources:
- web-log-deployment.yaml
- web-log-service.yaml
- web-log-ingress.yaml
- web-log-files-deployment.yaml
- web-log-files-service.yaml
- web-log-files-ingress.yaml
images:
- name: git.uuard.com/nicholas/web-log
newTag: 45b11ce88ad5a0e6854f9ba40710f56d66e6b955
+60
View File
@@ -0,0 +1,60 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-log
labels:
app.kubernetes.io/name: web-log
spec:
replicas: 2
revisionHistoryLimit: 5
progressDeadlineSeconds: 180
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app.kubernetes.io/name: web-log
template:
metadata:
labels:
app.kubernetes.io/name: web-log
spec:
imagePullSecrets:
- name: gitea-registry
containers:
- name: web-log
image: git.uuard.com/nicholas/web-log
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 80
protocol: TCP
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 2
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 6
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 100m
memory: 128Mi
securityContext:
allowPrivilegeEscalation: false
seccompProfile:
type: RuntimeDefault
+65
View File
@@ -0,0 +1,65 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-log-files
labels:
app.kubernetes.io/name: web-log-files
spec:
replicas: 2
revisionHistoryLimit: 5
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app.kubernetes.io/name: web-log-files
template:
metadata:
labels:
app.kubernetes.io/name: web-log-files
spec:
containers:
- name: web-log-files
image: nginx:1.30.3-alpine
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 80
protocol: TCP
readinessProbe:
tcpSocket:
port: http
initialDelaySeconds: 2
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 6
livenessProbe:
tcpSocket:
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 100m
memory: 128Mi
securityContext:
allowPrivilegeEscalation: false
seccompProfile:
type: RuntimeDefault
volumeMounts:
- name: web-log-files
mountPath: /usr/share/nginx/html
readOnly: true
volumes:
- name: web-log-files
nfs:
server: nas.hosts.uuard.com
path: /mnt/data-pool/web-log-files
readOnly: true
+19
View File
@@ -0,0 +1,19 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web-log-files
labels:
app.kubernetes.io/name: web-log-files
spec:
ingressClassName: traefik
rules:
- host: files.web-log.uuard.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web-log-files
port:
name: http
+15
View File
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: web-log-files
labels:
app.kubernetes.io/name: web-log-files
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: web-log-files
ports:
- name: http
port: 80
targetPort: http
protocol: TCP
+19
View File
@@ -0,0 +1,19 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web-log
labels:
app.kubernetes.io/name: web-log
spec:
ingressClassName: traefik
rules:
- host: web-log.uuard.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web-log
port:
name: http
+15
View File
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: web-log
labels:
app.kubernetes.io/name: web-log
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: web-log
ports:
- name: http
port: 80
targetPort: http
protocol: TCP