Adds container cluster example. (#2433)

This commit is contained in:
J Logan authored and GitHub committed 2026-10-27 08:44:52 -07:00
1 parent 804209b165
commit a448e95da2
21 files changed
+991

No files matched your search

@@ -0,0 +1 @@
.gateway/
@@ -0,0 +1,238 @@
# Example: Run a three-tier app on a container cluster behind a gateway
This example shows you how to use `container cluster` to run a small three-tier web application on a local Kubernetes cluster, and how to reach it from your Mac's browser by name through a gateway container.
The app is a shared notepad. Anyone who can reach the page can post a short note with their name. Notes are stored in [Valkey](https://valkey.io/) and served through a [Flask](https://flask.palletsprojects.com/) API, with a [React](https://react.dev/) frontend built with [Vite](https://vitejs.dev/) and served by [nginx](https://nginx.org/). The frontend polls for new notes every few seconds, so multiple browser windows stay in sync.
## Prerequisites
Install and start before running the demo:
- Apple `container` release 1.6.1 or later
- [`kubectl`](https://kubernetes.io/docs/tasks/tools/)
- The `test` DNS domain, configured as the default domain and registered with macOS, as described in [Set up DNS-based container names](../../docs/networking.md#set-up-dns-based-container-names)
- [Helm](https://helm.sh/)
## Architecture
```
┌──────────┐
│ Browser │ http://notepad.gateway.test
└────┬─────┘
│
┌────┴───────────────────────────┐
│ gateway nginx container │ network alias .gateway
└────┬───────────────────────────┘
│ NodePort 31080 on the worker nodes
┌────┴───────────────────────────┐
│ Traefik ingress controller │ routes by Host header
└────┬───────────────────────────┘
│
Ingress rules ──► web, api Services
│
└─► api ──► valkey
```
A cluster's nodes share one container network, and the pods reach each other through Kubernetes Services. The gateway is an nginx container on that same network. It is started with a network alias that has a leading dot:
```console
% container run -d --name gateway --network <nodes-network>,alias=.gateway ...
```
The alias makes every name below `gateway.test` resolve to the gateway, so `notepad.gateway.test` and `api.gateway.test` both reach it. The gateway forwards requests to Traefik on the worker nodes, and the Ingress rules in `ingress/ingress.yaml` use the `Host` header to choose between the `web` and `api` Services. Adding another name takes only another Ingress rule, with no change to DNS.
## Set up
Follow these steps to start the cluster and gateway, and build and deploy the application.
### 1. Build the images
```console
% container build -t demo-web:latest web
% container build -t demo-api:latest api
```
`./scripts/build.sh` runs both commands.
### 2. Create the cluster
```console
% container cluster create --workers 2
```
This pulls the Kubernetes node image on first use, starts a control plane node and two worker nodes, waits for the nodes to be Ready, and merges the cluster into `~/.kube/config`. The default `cluster-dev` cluster gets a kubectl context of the same name. To use another name, pass `--name` to `container cluster create` and `--context` or the cluster name to the scripts below.
### 3. Load the images
Load the images into the containerd of every node:
```console
% container cluster load-image --name cluster-dev demo-api:latest
% container cluster load-image --name cluster-dev demo-web:latest
```
The manifests refer to the images as `docker.io/library/demo-api:latest` and `docker.io/library/demo-web:latest`, which is the name that `container build` gives them.
### 4. Apply the manifests
```console
% kubectl --context cluster-dev apply -f deployment/
```
`./scripts/deploy.sh [cluster-name]` runs the image loading and this command.
### 5. Create the gateway
```console
% ./scripts/deploy-gateway.sh
```
The script installs Traefik as the ingress controller, applies the Ingress rules, finds the container network of the worker nodes, writes the gateway's nginx configuration under `.gateway/`, and starts the `gateway` container. When it finishes, it prints the app's address.
The script reads two environment variables when its defaults are wrong:
- `GATEWAY_NETWORK` is the container network that the cluster nodes are on. The default is found from the address of a worker node.
- `TRAEFIK_CHART_VERSION` pins the Traefik Helm chart. The default is the latest.
## Setup summary
Everything above, in one place, with the commands behind the scripts. Run it from this directory:
Containers and images, with `container`:
```console
# Build the images
container build -t demo-web:latest web
container build -t demo-api:latest api
# Create the cluster: one control plane node and two workers
container cluster create --workers 2
# Load the images into every node
container cluster load-image --name cluster-dev demo-api:latest
container cluster load-image --name cluster-dev demo-web:latest
```
Manifests, ingress and node addresses, with `kubectl` and `helm`:
```console
# Apply the app's manifests
kubectl --context cluster-dev apply -f deployment/
# Install Traefik as the ingress controller, on NodePort 31080 of every node
helm upgrade --install traefik traefik \
--repo https://traefik.github.io/charts \
--kube-context cluster-dev --namespace traefik --create-namespace \
--set service.type=NodePort \
--set ports.web.nodePort=31080 \
--set ports.websecure.nodePort=31443 \
--set deployment.replicas=2 \
--wait
# Route notepad.gateway.test and api.gateway.test to the web and api Services
kubectl --context cluster-dev apply -f ingress/ingress.yaml
# Find the worker nodes' addresses
kubectl --context cluster-dev get nodes --selector '!node-role.kubernetes.io/control-plane' \
--output 'jsonpath={range .items[*]}{.status.addresses[?(@.type=="InternalIP")].address}{"\n"}{end}'
```
The gateway, with `container`:
```console
# Find the container network that the nodes are on
container inspect $(container list --quiet) | jq -r '.[].status.networks[]? | .network + " " + .ipv4Address'
# Write the gateway's nginx configuration, with one "server <node-ip>:31080;" line
# per worker node, then start the gateway on the nodes' network
mkdir -p .gateway/gateway
cat > .gateway/gateway/default.conf <<'CONF'
upstream cluster_ingress {
server <worker-1-ip>:31080;
server <worker-2-ip>:31080;
}
server {
listen 80 default_server;
location / {
proxy_pass http://cluster_ingress;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
CONF
container run -d --name gateway \
--network <nodes-network>,alias=.gateway \
--mount type=bind,source="$PWD/.gateway/gateway",target=/etc/nginx/conf.d,readonly \
nginx:alpine
```
`./scripts/build.sh`, `./scripts/deploy.sh` and `./scripts/deploy-gateway.sh` run the build, load-and-apply, and gateway commands. The gateway script finds the node addresses and the network for you.
## Examine what was deployed
Use the `container` CLI to see the containers and network behind the cluster:
```console
# The cluster
container cluster list
# The control plane and worker node containers, and the gateway container
container list
# The network that the nodes and the gateway share
container network list
# The gateway's addresses and its .gateway alias
container inspect gateway | jq '.[0].status.networks'
# The images that were built
container image list | grep demo-
```
Use `kubectl` to see what runs inside the cluster:
```console
# The nodes, with their container network addresses
kubectl --context cluster-dev get nodes -o wide
# The app's pods and which worker node each runs on
kubectl --context cluster-dev get pods -o wide
# The Services that connect web, api and valkey
kubectl --context cluster-dev get services
# The Ingress rules that route notepad.gateway.test and api.gateway.test
kubectl --context cluster-dev get ingress
# The Traefik ingress controller
kubectl --context cluster-dev get pods --namespace traefik
```
## Try the application and API
1. Open `http://notepad.gateway.test` in a browser.
2. Duplicate the tab into a new window and arrange the two windows side by side.
3. Enter a name and post a note in the first window, then post a note from the second window. Each note appears in both windows within a few seconds.
4. Look at the notes on the API server: open `http://api.gateway.test/api/notes` in the browser, or run:
```console
% curl -s http://api.gateway.test/api/notes | jq
```
## Clean up
Remove the gateway, the ingress rules and Traefik:
```console
% ./scripts/remove-gateway.sh
```
Stop and delete the cluster, including its worker nodes:
```console
% container cluster delete --name cluster-dev
```
@@ -0,0 +1,6 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]
@@ -0,0 +1,38 @@
import json
import os
from datetime import datetime, timezone
from flask import Flask, jsonify, request
from flask_cors import CORS
import valkey
app = Flask(__name__)
CORS(app)
kv = valkey.Valkey(
host=os.getenv('VALKEY_HOST', 'localhost'),
port=6379,
decode_responses=True,
)
@app.route('/api/notes', methods=['GET'])
def get_notes():
raw = kv.lrange('notes', 0, -1)
return jsonify([json.loads(n) for n in raw])
@app.route('/api/notes', methods=['POST'])
def add_note():
data = request.get_json()
note = {
'author': data.get('author') or 'Anonymous',
'text': data.get('text', '').strip(),
'timestamp': datetime.now(timezone.utc).isoformat(),
}
kv.lpush('notes', json.dumps(note))
return jsonify(note), 201
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
@@ -0,0 +1,3 @@
flask
flask-cors
valkey
@@ -0,0 +1,40 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
replicas: 1
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
containers:
- name: api
image: docker.io/library/demo-api:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 5000
env:
- name: VALKEY_HOST
value: valkey
readinessProbe:
httpGet:
path: /api/notes
port: 5000
initialDelaySeconds: 3
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: api
spec:
selector:
app: api
ports:
- port: 5000
targetPort: 5000
@@ -0,0 +1,30 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: valkey
spec:
replicas: 1
selector:
matchLabels:
app: valkey
template:
metadata:
labels:
app: valkey
spec:
containers:
- name: valkey
image: valkey/valkey:8
ports:
- containerPort: 6379
---
apiVersion: v1
kind: Service
metadata:
name: valkey
spec:
selector:
app: valkey
ports:
- port: 6379
targetPort: 6379
@@ -0,0 +1,64 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: web-nginx-conf
data:
default.conf: |
server {
listen 80;
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://api:5000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 1
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: web
image: docker.io/library/demo-web:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 80
volumeMounts:
- name: nginx-conf
mountPath: /etc/nginx/conf.d/default.conf
subPath: default.conf
volumes:
- name: nginx-conf
configMap:
name: web-nginx-conf
---
# NodePort 30080: accessible at http://<node-ip>:30080 on any node, without port-forward.
# Find the node IPs with: kubectl get nodes -o wide
apiVersion: v1
kind: Service
metadata:
name: web
spec:
type: NodePort
selector:
app: web
ports:
- port: 80
targetPort: 80
nodePort: 30080
@@ -0,0 +1,36 @@
# Routes requests that reach the cluster's ingress controller by host name.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: notepad
spec:
ingressClassName: traefik
rules:
- host: notepad.gateway.test
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web
port:
number: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: notepad-api
spec:
ingressClassName: traefik
rules:
- host: api.gateway.test
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: api
port:
number: 5000
@@ -0,0 +1,26 @@
#!/bin/bash
# Copyright © 2026 Apple Inc. and the container project authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
set -e
EXAMPLE_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
run() {
echo "==> RUN: $*"
"$@"
}
run container build -t demo-web:latest "$EXAMPLE_ROOT/web"
run container build -t demo-api:latest "$EXAMPLE_ROOT/api"
@@ -0,0 +1,155 @@
#!/bin/bash
# Copyright © 2026 Apple Inc. and the container project authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Puts a gateway in front of a local Kubernetes cluster, and routes to the app by host name.
#
# browser -> gateway container -> NodePort on the worker nodes -> Traefik -> Ingress -> Service
#
# The gateway is an nginx container on the cluster's container network. Its network alias
# ".gateway" makes every name below gateway.test resolve to it, so notepad.gateway.test and
# api.gateway.test both go to the gateway, and Traefik picks the Service from the Host header.
#
# Works for clusters made with "container cluster create". Run it after deploy.sh.
#
# Usage: deploy-gateway.sh [--context <kube-context>] (default context: cluster-dev)
#
# Environment:
# GATEWAY_NETWORK container network of the cluster nodes (default: found from a worker node)
# TRAEFIK_CHART_VERSION Traefik chart version to install (default: the latest)
set -e
EXAMPLE_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
CONTEXT="cluster-dev"
GATEWAY="gateway"
# The gateway's names are below this host. It assumes the default DNS domain is "test".
GATEWAY_HOST="gateway.test"
INGRESS_HTTP_PORT=31080
INGRESS_HTTPS_PORT=31443
while [[ $# -gt 0 ]]; do
case $1 in
--context) CONTEXT="$2"; shift 2 ;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
run() {
echo "==> RUN: $*"
"$@"
}
KUBECTL=(kubectl --context "$CONTEXT")
# Worker nodes, or all nodes if the cluster has no dedicated workers.
node_ips() {
"${KUBECTL[@]}" get nodes "$@" \
--output 'jsonpath={range .items[*]}{.status.addresses[?(@.type=="InternalIP")].address}{"\n"}{end}'
}
WORKER_SELECTOR='!node-role.kubernetes.io/control-plane'
NODE_IPS="$(node_ips --selector "$WORKER_SELECTOR")"
if [[ -z "$NODE_IPS" ]]; then
NODE_IPS="$(node_ips)"
fi
echo "==> Gateway upstream nodes: $(echo $NODE_IPS)"
# The gateway has to be on the container network that the nodes are on. Find the network by the
# address of a node, which does not depend on how the cluster tool names its nodes.
if [[ -z "$GATEWAY_NETWORK" ]]; then
FIRST_NODE_IP="$(echo "$NODE_IPS" | head -n 1)"
GATEWAY_NETWORK="$(container inspect $(container list --quiet) |
jq -r --arg ip "$FIRST_NODE_IP" '[.[].status.networks[]? | select(.ipv4Address | startswith($ip + "/")) | .network][0] // empty')"
if [[ -z "$GATEWAY_NETWORK" ]]; then
echo "Could not find the container network of the node at $FIRST_NODE_IP. Set GATEWAY_NETWORK."
exit 1
fi
fi
echo "==> Gateway network: $GATEWAY_NETWORK"
# Install Traefik as the ingress controller, reachable on a NodePort of every node.
TRAEFIK_VERSION=()
if [[ -n "$TRAEFIK_CHART_VERSION" ]]; then
TRAEFIK_VERSION=(--version "$TRAEFIK_CHART_VERSION")
fi
run helm upgrade --install traefik traefik \
--repo https://traefik.github.io/charts "${TRAEFIK_VERSION[@]}" \
--kube-context "$CONTEXT" --namespace traefik --create-namespace \
--set service.type=NodePort \
--set ports.web.nodePort="$INGRESS_HTTP_PORT" \
--set ports.websecure.nodePort="$INGRESS_HTTPS_PORT" \
--set deployment.replicas=2 \
--wait
# Route notepad.<gateway> and api.<gateway> to the app's Services.
run "${KUBECTL[@]}" apply -f "$EXAMPLE_ROOT/ingress/ingress.yaml"
# Generate the gateway's nginx configuration. It balances across the nodes' ingress NodePort.
CONF_DIR="$EXAMPLE_ROOT/.gateway/$GATEWAY"
mkdir -p "$CONF_DIR"
{
echo "upstream cluster_ingress {"
for ip in $NODE_IPS; do
echo " server ${ip}:${INGRESS_HTTP_PORT};"
done
cat <<'CONF'
}
server {
listen 80 default_server;
location / {
proxy_pass http://cluster_ingress;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
CONF
} >"$CONF_DIR/default.conf"
# The alias has a leading dot, so every name below the gateway's name resolves to it,
# and it gets the default domain, so .gateway means .gateway.test.
run container stop "$GATEWAY" 2>/dev/null || true
run container delete "$GATEWAY" 2>/dev/null || true
run container run -d --name "$GATEWAY" \
--network "${GATEWAY_NETWORK},alias=.${GATEWAY}" \
--mount "type=bind,source=${CONF_DIR},target=/etc/nginx/conf.d,readonly" \
nginx:alpine
GATEWAY_IP="$(container inspect "$GATEWAY" | jq -r '.[0].status.networks[0].ipv4Address' | cut -d/ -f1)"
# Traefik can take a few seconds to pick up the Ingress rules, and until it does it answers 404.
ATTEMPTS=30
echo "==> Waiting for http://notepad.${GATEWAY_HOST} through the gateway at ${GATEWAY_IP}"
for attempt in $(seq 1 "$ATTEMPTS"); do
status="$(curl -s -o /dev/null -w '%{http_code}' --max-time 2 \
-H "Host: notepad.${GATEWAY_HOST}" "http://${GATEWAY_IP}/" || true)"
if [[ "$status" == 2* ]]; then
echo
echo "Run these commands from your terminal to access the web application and message API:"
echo
echo " open http://notepad.${GATEWAY_HOST}"
echo " curl -s http://api.${GATEWAY_HOST}/api/notes | jq"
exit 0
fi
if [[ "$status" == "000" ]]; then
status="no answer"
fi
echo " not ready yet (${status}), attempt ${attempt} of ${ATTEMPTS}"
sleep 2
done
echo "The app did not answer through the gateway (last result: ${status})."
echo "Check 'kubectl --context ${CONTEXT} get pods -A' and 'kubectl --context ${CONTEXT} get ingress'."
exit 1
@@ -0,0 +1,32 @@
#!/bin/bash
# Copyright © 2026 Apple Inc. and the container project authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
set -e
# The cluster name is also the kubectl context that "container cluster create" adds to ~/.kube/config.
CLUSTER="${1:-cluster-dev}"
EXAMPLE_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
run() {
echo "==> RUN: $*"
"$@"
}
# Loads each image into every node of the cluster. "container build" names the images
# docker.io/library/demo-*:latest, which is the name that the manifests in deployment/ use.
run container cluster load-image --name "$CLUSTER" demo-api:latest
run container cluster load-image --name "$CLUSTER" demo-web:latest
run kubectl --context "$CLUSTER" apply -f "$EXAMPLE_ROOT/deployment/"
@@ -0,0 +1,43 @@
#!/bin/bash
# Copyright © 2026 Apple Inc. and the container project authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Removes what deploy-gateway.sh created: the gateway container, the ingress rules and Traefik.
#
# Usage: remove-gateway.sh [--context <kube-context>]
set -e
EXAMPLE_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
CONTEXT="cluster-dev"
GATEWAY="gateway"
while [[ $# -gt 0 ]]; do
case $1 in
--context) CONTEXT="$2"; shift 2 ;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
run() {
echo "==> RUN: $*"
"$@"
}
run container stop "$GATEWAY" 2>/dev/null || true
run container delete "$GATEWAY" 2>/dev/null || true
run rm -rf "$EXAMPLE_ROOT/.gateway/$GATEWAY"
run kubectl --context "$CONTEXT" delete ingress notepad notepad-api --ignore-not-found
run helm uninstall traefik --kube-context "$CONTEXT" --namespace traefik 2>/dev/null || true
@@ -0,0 +1,10 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shared Notepad</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
@@ -0,0 +1,14 @@
server {
listen 80;
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://api.test:5000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
@@ -0,0 +1,18 @@
{
"name": "shared-notepad",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.0",
"vite": "^5.0.0"
}
}
@@ -0,0 +1,133 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f0f2f5;
color: #333;
}
.app {
max-width: 680px;
margin: 0 auto;
padding: 2rem 1rem;
}
header {
text-align: center;
margin-bottom: 2rem;
}
header h1 {
font-size: 2rem;
font-weight: 700;
color: #1a1a1a;
}
header p {
margin-top: 0.25rem;
font-size: 0.85rem;
color: #888;
letter-spacing: 0.03em;
}
form {
background: white;
border-radius: 10px;
padding: 1.25rem;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
margin-bottom: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
input,
textarea {
width: 100%;
padding: 0.6rem 0.8rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 0.95rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
input:focus,
textarea:focus {
border-color: #4a90d9;
}
textarea {
resize: vertical;
min-height: 80px;
}
button {
align-self: flex-end;
padding: 0.55rem 1.4rem;
background: #4a90d9;
color: white;
border: none;
border-radius: 6px;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.15s;
}
button:hover:not(:disabled) {
background: #357abd;
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.notes {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.note {
background: white;
border-radius: 10px;
padding: 1rem 1.25rem;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
.note-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.4rem;
}
.note-header strong {
font-size: 0.9rem;
color: #1a1a1a;
}
.note-header span {
font-size: 0.8rem;
color: #aaa;
}
.note p {
font-size: 0.95rem;
line-height: 1.55;
color: #444;
white-space: pre-wrap;
}
.empty {
text-align: center;
color: #aaa;
padding: 2rem 0;
font-size: 0.9rem;
}
@@ -0,0 +1,76 @@
import { useState, useEffect } from 'react'
export default function App() {
const [notes, setNotes] = useState([])
const [author, setAuthor] = useState('')
const [text, setText] = useState('')
const [posting, setPosting] = useState(false)
const fetchNotes = () =>
fetch('/api/notes')
.then(r => r.json())
.then(setNotes)
.catch(() => {})
useEffect(() => {
fetchNotes()
const id = setInterval(fetchNotes, 3000)
return () => clearInterval(id)
}, [])
const handleSubmit = async e => {
e.preventDefault()
if (!text.trim()) return
setPosting(true)
await fetch('/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ author: author.trim() || 'Anonymous', text: text.trim() }),
})
setText('')
await fetchNotes()
setPosting(false)
}
return (
<div className="app">
<header>
<h1>Shared Notepad</h1>
<p>React &middot; Flask &middot; Valkey</p>
</header>
<form onSubmit={handleSubmit}>
<input
placeholder="Your name (optional)"
value={author}
onChange={e => setAuthor(e.target.value)}
/>
<textarea
placeholder="Write a note..."
value={text}
onChange={e => setText(e.target.value)}
required
/>
<button type="submit" disabled={posting}>
{posting ? 'Posting...' : 'Post Note'}
</button>
</form>
<div className="notes">
{notes.length === 0 ? (
<p className="empty">No notes yet. Be the first to post.</p>
) : (
notes.map((note, i) => (
<div key={i} className="note">
<div className="note-header">
<strong>{note.author}</strong>
<span>{new Date(note.timestamp).toLocaleTimeString()}</span>
</div>
<p>{note.text}</p>
</div>
))
)}
</div>
</div>
)
}
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './App.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>
)
@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})